How to Find the Nearest Coordinates With a BallTree in Python

On this page

Problem statement

For every point in one table, find the closest point in another: the nearest hospital to each address, the nearest weather station to each farm, the nearest large airport to every airfield. With latitude and longitude spread across the globe, projecting to one CRS distorts distances, and comparing every pair is quadratic. A BallTree with the haversine metric gives exact great-circle nearest neighbours for hundreds of thousands of points in seconds, and also the second, third and k-th nearest.

This page finds the nearest large airport to each of the 80,000 places in OurAirports, checks the result against brute force, finds several neighbours at once, and compares the distance to the nearest airport across countries.

Quick answer

Build the tree on the targets, query it with the sources, and convert the angular distances to kilometres:

from sklearn.neighbors import BallTree
R = 6371.0088
tree = BallTree(np.radians(L[["latitude_deg", "longitude_deg"]].values), metric="haversine")
dist, ind = tree.query(np.radians(A[["latitude_deg", "longitude_deg"]].values), k=1)
A["nearest_large"] = L.iata_code.values[ind[:, 0]]
A["km_to_large"] = dist[:, 0] * R
print(f"{len(A):,} places matched to {len(L):,} large airports; median distance {A.km_to_large.median():.0f} km, 90th percentile {A.km_to_large.quantile(0.9):.0f} km")
print(A.nlargest(3, "km_to_large")[["name", "iso_country", "nearest_large", "km_to_large"]].round(0).to_string(index=False))
72,603 places matched to 1,172 large airports; median distance 104 km, 90th percentile 393 km
                    name iso_country nearest_large  km_to_large
           Vostok Skiway          AQ           HBA       4334.0
       Wolfs Fang Runway          AQ           PUQ       4186.0
Novolazarevskaya Station          AQ           CPT       4120.0

The median place is 104 km from its nearest large airport and 90% are within 393 km. The farthest are Antarctic stations: Vostok's skiway is 4,334 km from Hobart, the nearest large airport.

World map of 72,603 airfields coloured by great-circle distance to the nearest large airport on a logarithmic scale from 5 to 3,000 kilometres, with large airports in blue: short distances in Europe, the eastern United States and Japan, long ones in the Amazon, interior Australia, northern Canada and Siberia. World map of 72,603 airfields coloured by great-circle distance to the nearest large airport on a logarithmic scale from 5 to 3,000 kilometres, with large airports in blue: short distances in Europe, the eastern United States and Japan, long ones in the Amazon, interior Australia, northern Canada and Siberia.
Distance to the nearest large airport.

Check against brute force, several neighbours, by country

1. Check against brute force

For a sample of places, compute the haversine distance to every large airport and take the minimum; it should match the tree exactly:

def haversine_km(lat1, lon1, lat2, lon2):
    lat1, lon1, lat2, lon2 = map(np.radians, (lat1, lon1, lat2, lon2))
    a = np.sin((lat2 - lat1) / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin((lon2 - lon1) / 2) ** 2
    return 2 * R * np.arcsin(np.sqrt(a))
S = A.sample(2000, random_state=1)
D = haversine_km(S.latitude_deg.values[:, None], S.longitude_deg.values[:, None], L.latitude_deg.values[None, :], L.longitude_deg.values[None, :])
same = (L.iata_code.values[D.argmin(axis=1)] == S.nearest_large.values)
print(f"sample {len(S):,}: same nearest airport in {same.mean():.2%}; largest distance difference {np.abs(D.min(axis=1) - S.km_to_large.values).max() * 1000:.3f} m")
sample 2,000: same nearest airport in 100.00%; largest distance difference 0.000 m

For a random sample of 2,000 places, brute force picks the same nearest airport every time and the distances agree to the millimetre.

2. More than one neighbour

k returns the k nearest, sorted. The gap between the nearest and second nearest says how clear-cut the assignment is:

dist3, ind3 = tree.query(np.radians(A[["latitude_deg", "longitude_deg"]].values), k=3)
km3 = dist3 * R
A["gap_km"] = km3[:, 1] - km3[:, 0]
print(f"median distance to the 1st, 2nd and 3rd nearest large airport: {np.median(km3, axis=0).round(0).tolist()} km")
print(f"places where the second nearest is within 5 km of the nearest: {(A.gap_km < 5).mean():.1%}")
median distance to the 1st, 2nd and 3rd nearest large airport: [104.0, 199.0, 273.0] km
places where the second nearest is within 5 km of the nearest: 5.3%

The median distances to the nearest, second and third nearest large airport are 104, 199 and 273 km. For 5.3% of places the second nearest is within 5 km of the nearest: in those cases a small coordinate error could change the answer.

Nearest-neighbour matching with a BallTree. nearest neighbours with a BallTree1targets to radians1,172 large airports2BallTree(metric="haversine")build once3query(sources, k=1)72,603 places4distance x 6,371.0088kilometres5check a sample100% match with brute force6report the gap to the 2nd5.3% within 5 kmExact on the sphere; no antimeridian seam spatialworkflow.io
Nearest neighbours with a BallTree.

3. By country

Summarise the distance to the nearest large airport for the countries with the most places:

top = A.iso_country.value_counts().head(8).index
summary = A[A.iso_country.isin(top)].groupby("iso_country").km_to_large.agg(places="size", median="median", p90=lambda s: s.quantile(0.9)).round(0)
print(summary.sort_values("median").to_string())
             places  median    p90
iso_country                       
JP             3328    27.0  102.0
FR             1672    79.0  151.0
US            25081   104.0  282.0
MX             2086   129.0  284.0
CA             2480   177.0  762.0
RU             1504   240.0  913.0
BR             7738   259.0  561.0
AU             2683   319.0  830.0

Japan is the best served: the median place is 27 km from a large airport. The United States has 25,081 places, a median of 104 km, and Canada, Russia, Brazil and Australia have medians of 177 to 319 km, with long tails: in Russia 10% of places are more than 913 km away.

Timing the tree

The data used on this page

Every place in OurAirports, a public-domain database, excluding closed ones, and its large airports with an IATA code as the targets.

from pathlib import Path

import numpy as np
import pandas as pd
import requests

Path("data").mkdir(exist_ok=True)
p = Path("data/airports.csv")
if not p.exists():
    r = requests.get("https://davidmegginson.github.io/ourairports-data/airports.csv", timeout=300)
    r.raise_for_status()
    p.write_bytes(r.content)
A = pd.read_csv(p, keep_default_na=False, na_values=[""])
A = A[A.type != "closed"].reset_index(drop=True)
L = A[(A.type == "large_airport") & A.iata_code.notna()].reset_index(drop=True)
print(f"{len(A):,} places; {len(L):,} large airports as targets")
72,603 places; 1,172 large airports as targets

The sources are all 72,603 open places in OurAirports; the targets are the 1,172 large airports with an IATA code.

Example 1: timing the tree

Build and query time, against the brute-force matrix for the same points. Timings vary between runs, so the block prints the comparisons rather than the times:

import time
X = np.radians(A[["latitude_deg", "longitude_deg"]].values)
t0 = time.perf_counter(); T = BallTree(np.radians(L[["latitude_deg", "longitude_deg"]].values), metric="haversine"); t_build = time.perf_counter() - t0
t0 = time.perf_counter(); T.query(X, k=1); t_query = time.perf_counter() - t0
t0 = time.perf_counter()
for chunk in np.array_split(A[["latitude_deg", "longitude_deg"]].values, 40):
    haversine_km(chunk[:, :1], chunk[:, 1:], L.latitude_deg.values[None, :], L.longitude_deg.values[None, :]).argmin(axis=1)
t_brute = time.perf_counter() - t0
print(f"tree built in under 50 ms: {t_build < 0.05}; all {len(X):,} queries faster than brute force: {t_query < t_brute}")
tree built in under 50 ms: True; all 72,603 queries faster than brute force: True

Building the tree on 1,172 targets takes about a millisecond, and querying all 72,603 places a fraction of a second, several times faster than a chunked brute-force matrix on this machine (0.57 s against 2.16 s in one run). With more targets the gap widens, because brute force grows with their number and the tree only with its logarithm.

Box plots of the distance from each airfield to the nearest large airport for eight countries on a logarithmic axis, ordered by median: Japan lowest, then France, the United States, Mexico, Canada, Russia, Brazil and Australia, with the widest spreads in Canada and Russia. Box plots of the distance from each airfield to the nearest large airport for eight countries on a logarithmic axis, ordered by median: Japan lowest, then France, the United States, Mexico, Canada, Russia, Brazil and Australia, with the widest spreads in Canada and Russia.
By country.

Descending a tree of nested balls

Median distance from airfields to the nearest large airport by country. median km to the nearest large airportJapan27 kmFrance79 kmUnited States104 kmCanada177 kmRussia240 kmAustralia319 km0100 km200 km300 kmWorld median 104 km; Vostok, Antarctica: 4,334 km spatialworkflow.io
Median distance by country.

Nearest-neighbour search with a BallTree descends the tree of nested balls, keeping the best candidates found so far and pruning any ball whose closest possible point is farther than the current k-th best. With the haversine metric the coordinates are angles on a unit sphere, so the search is exact on the sphere and has no edges at the antimeridian or problems near the poles, unlike a search in projected coordinates. Distances come back in radians; multiplying by the Earth's radius gives kilometres.

Nearest is a geometric answer, not always the useful one. The nearest large airport across a strait or a border may be unreachable by road; the second nearest may be almost as close, so small coordinate errors can flip the assignment; and distance along the ground is a lower bound on travel distance. For travel time, follow up with a routing engine; for assignments near ties, report the gap.

Self-matches, ties and memory

Nearest-neighbour matching: problems and fixes. nearest-neighbour trapsTrapInsteadself-match in one tablek=2, drop the firstnear tiesreport the gap to the 2ndnearest across waterroute for travel distanceprojected CRS for the globehaversine on the sphere72,603 places, 1,172 targets spatialworkflow.io
Nearest-neighbour traps.
  • Self-matches: when sources and targets are the same table, ask for k=2 and drop the first result, which is the point itself.
  • Ties: equidistant targets are returned in an arbitrary but deterministic order.
  • Projected alternative: for a city or a region, geopandas.sjoin_nearest in a local projected CRS is simpler and returns metres.
  • Memory: the tree on 1,000 targets is tiny; on millions of targets it fits easily, but returning k large neighbours for millions of sources does not.

FAQ

How do I find the nearest point for each row in Python?

Build BallTree(np.radians(targets[["lat", "lon"]]), metric="haversine") and call tree.query(np.radians(sources[["lat", "lon"]]), k=1); multiply the returned distances by 6,371 for kilometres.

Is the BallTree result exact?

Yes, for great-circle distance on a sphere: it matches a brute-force haversine minimum exactly, as step 1 shows.

How do I get the second nearest point?

Query with k=2 or more; results are sorted by distance.

When should I use sjoin_nearest instead?

When the data covers a small area that one projected CRS represents well; it returns distances in the CRS's units and keeps the GeoDataFrame workflow.