Ripley's K Function Implementation Guide
TL;DR — Call
ripley_k(coords, distances, window_polygon, n_simulations=999)with a NumPy(N, 2)projected coordinate array, a 1-D distance vector, and a ShapelyPolygonobservation window. The function returnsk_obs(empirical K values) andk_sim(simulation matrix). Comparek_obsagainstnp.pi * distances**2to detect clustering (k_obs > πd²) or dispersion (k_obs < πd²).
Why This Matters
Ripley’s K function is the workhorse second-order statistic in point pattern analysis, evaluating whether point interactions — clustering, inhibition, or randomness — exist across a continuous range of spatial scales. Unlike the nearest-neighbour index, which collapses everything to a single scalar, K(d) exposes scale-dependent structure: a process may cluster at neighbourhood scale (d < 200 m) while exhibiting inhibition at micro-scale (d < 20 m) due to territorial constraints. This makes it indispensable for ecological species distribution studies, epidemiological hotspot detection, and urban infrastructure optimisation. The technique sits within the broader framework of Core Concepts of Spatial Statistics & Geostatistics as the canonical test for second-order spatial dependence, complementing first-order intensity surfaces from kernel density estimation and attribute-based tests like spatial autocorrelation metrics.
Environment and Version Pinning
# Install exact versions for reproducibility
# pip install numpy==1.26.4 scipy==1.13.1 shapely==2.0.4 matplotlib==3.9.0
import numpy as np # 1.26.4
from scipy.spatial import cKDTree # scipy 1.13.1
from shapely.geometry import Polygon, Point, MultiPoint # shapely 2.0.4
import matplotlib.pyplot as plt # 3.9.0
CRS requirement: All coordinates must be in a projected system (e.g., EPSG:32618 UTM Zone 18N, or a local planar CRS). The distances array must share the same linear unit as the coordinates (metres if UTM). Geographic coordinates (decimal degrees) will silently produce incorrect K values because the πd² baseline assumes Euclidean geometry.
Step-by-Step Implementation
Step 1 — Define the Observation Window
The observation window must tightly bound the area where events could theoretically occur. An over-large window inflates the denominator area |W| and suppresses the estimated intensity λ, biasing K upward (false clustering).
from shapely.geometry import Polygon
# Real use: load from a GeoDataFrame or GeoJSON
# window = gpd.read_file("study_area.gpkg").geometry.union_all()
# Synthetic example: 100 × 100 m square (projected, metres)
window = Polygon([(0, 0), (100, 0), (100, 100), (0, 100)])
# Quick sanity checks
assert window.is_valid, "Window polygon must be topologically valid"
assert window.area > 0, "Window must have positive area"
Step 2 — Compute Empirical K(d) with Isotropic Edge Correction
Raw neighbor counts suffer from boundary truncation: points near the window edge have fewer visible neighbors because the search radius extends outside the study area. The isotropic correction weights each focal point by the proportion of its search circle that lies inside the window.
def ripley_k(coords, distances, window_polygon, n_simulations=999):
"""
Compute Ripley's K function with isotropic edge correction and
a Monte Carlo CSR simulation envelope.
Parameters
----------
coords : np.ndarray, shape (N, 2)
Projected point coordinates in metres.
distances : np.ndarray, shape (M,)
Evaluation distances (same unit as coords).
window_polygon : shapely.geometry.Polygon
Study area boundary in the same CRS as coords.
n_simulations : int
CSR simulations for the envelope. Use >=999 for publication.
Returns
-------
k_obs : np.ndarray, shape (M,) Observed K(d)
k_sim : np.ndarray, shape (n_simulations, M) Simulated K(d) matrix
"""
n = len(coords)
area = window_polygon.area
lam = n / area # global intensity: events per unit area
tree = cKDTree(coords)
# --- Observed K with isotropic (Ripley) edge correction ---
k_obs = np.zeros(len(distances))
for j, d in enumerate(distances):
total = 0.0
for i, pt in enumerate(coords):
# Proportion of the search circle that falls inside the window
circle = Point(pt).buffer(d, resolution=32)
clipped_area = circle.intersection(window_polygon).area
if clipped_area < 1e-12:
continue
# Edge weight: upscale count to compensate for hidden sector
edge_weight = (np.pi * d ** 2) / clipped_area
count = len(tree.query_ball_point(pt, d)) - 1 # exclude self
total += count * edge_weight
k_obs[j] = total / (lam ** 2 * area)
# --- CSR simulation envelope (homogeneous Poisson, same n and window) ---
xmin, ymin, xmax, ymax = window_polygon.bounds
k_sim = np.zeros((n_simulations, len(distances)))
rng = np.random.default_rng(42)
for sim in range(n_simulations):
# Rejection sampling: uniform candidates filtered to window interior
sim_pts = []
while len(sim_pts) < n:
candidates = np.column_stack([
rng.uniform(xmin, xmax, n),
rng.uniform(ymin, ymax, n),
])
inside = np.array([
window_polygon.contains(Point(c)) for c in candidates
])
sim_pts.extend(candidates[inside].tolist())
sim_pts = np.array(sim_pts[:n])
sim_tree = cKDTree(sim_pts)
for j, d in enumerate(distances):
counts = np.array([
len(sim_tree.query_ball_point(p, d)) - 1 for p in sim_pts
])
# No edge correction for simulated data (they already fill the window)
k_sim[sim, j] = counts.sum() / (lam ** 2 * area)
return k_obs, k_sim
Step 3 — Generate Clustered Test Data and Run
# Synthetic two-cluster pattern + random background
rng = np.random.default_rng(42)
pts = np.vstack([
rng.normal([30, 30], 4, (25, 2)), # cluster A
rng.normal([70, 70], 4, (25, 2)), # cluster B
rng.uniform(0, 100, (20, 2)), # background
])
# Clip to window to remove escaped points
xmin, ymin, xmax, ymax = window.bounds
mask = (
(pts[:, 0] > xmin) & (pts[:, 0] < xmax) &
(pts[:, 1] > ymin) & (pts[:, 1] < ymax)
)
pts = pts[mask]
distances = np.linspace(1, 30, 60)
k_obs, k_sim = ripley_k(pts, distances, window, n_simulations=199)
Step 4 — Apply the L-Function Transformation
The theoretical K baseline is K(d) = πd², a quadratic curve that amplifies visual variance at large distances. The L-function linearises the expectation and centres it at zero:
Under complete spatial randomness, . Values above zero indicate clustering; values below zero indicate regularity.
def l_transform(k_values, distances):
"""Apply the L-function variance-stabilising transform."""
return np.sqrt(k_values / np.pi) - distances
l_obs = l_transform(k_obs, distances)
l_sim = l_transform(k_sim, distances)
l_lo = l_sim.min(axis=0)
l_hi = l_sim.max(axis=0)
Step 5 — Visualise the Results
fig, axes = plt.subplots(1, 2, figsize=(13, 5))
# Panel 1: Raw K(d)
k_lo = k_sim.min(axis=0)
k_hi = k_sim.max(axis=0)
axes[0].fill_between(distances, k_lo, k_hi, alpha=0.2, color="gray",
label=f"CSR Envelope ({k_sim.shape[0]} sims)")
axes[0].plot(distances, k_obs, color="#e05c00", linewidth=2.5, label="Observed K(d)")
axes[0].plot(distances, np.pi * distances ** 2, "k--", label="Theoretical CSR (πd²)")
axes[0].set_xlabel("Distance d (m)")
axes[0].set_ylabel("K(d)")
axes[0].set_title("Ripley's K Function")
axes[0].legend(loc="upper left")
axes[0].grid(alpha=0.3)
# Panel 2: L-function
axes[1].fill_between(distances, l_lo, l_hi, alpha=0.2, color="gray",
label="CSR Envelope")
axes[1].plot(distances, l_obs, color="#e05c00", linewidth=2.5, label="Observed L(d)")
axes[1].axhline(0, color="black", linestyle="--", label="CSR baseline (L=0)")
axes[1].set_xlabel("Distance d (m)")
axes[1].set_ylabel("L(d)")
axes[1].set_title("L-Function (Variance-Stabilised)")
axes[1].legend(loc="upper left")
axes[1].grid(alpha=0.3)
plt.tight_layout()
plt.savefig("ripley_k_output.png", dpi=150)
plt.show()
Visualising the K-Function Workflow
The diagram below illustrates how raw coordinates flow through edge correction, simulation, and the L-function transform to produce the final inference.
Interpreting the Output
| Output | What it Means |
|---|---|
k_obs[j] > np.pi * distances[j]**2 |
Clustering at scale distances[j]: more neighbors observed than random expectation |
k_obs[j] < np.pi * distances[j]**2 |
Inhibition / regularity: inter-point repulsion or packing constraints |
k_obs inside the CSR envelope |
No statistically detectable departure from complete spatial randomness |
l_obs strongly positive at small d, flipping negative at large d |
Multi-scale structure — micro-clustering with macro-regularity (common in plant ecology) |
| Wide CSR envelope even with 999 sims | Low point count (N < 50): K-function has high sampling variance; consider reporting the L-function instead |
Critical Best Practices
Always Use a Projected CRS
Distances in the πd² formula are Euclidean. Using geographic coordinates (decimal degrees) makes the theoretical baseline wrong by orders of magnitude for anything beyond a few kilometres. Reproject with geopandas: gdf = gdf.to_crs("EPSG:32618") before extracting coordinates.
Match the Window to Actual Study Area
The global intensity λ = N / |W| is the denominator of K. If the window includes large uninhabitable regions (water bodies, restricted zones) where events cannot occur, λ is underestimated and K is inflated. Use the ecologically or administratively valid boundary, not a bounding box.
Use 999+ Simulations for Publication
With 99 simulations, the rank-based envelope test has an effective significance level of approximately 2/(99+1) = 0.02. For p < 0.005, use 999 simulations (alpha = 2/1000 = 0.002). The 199-simulation default in the example gives alpha ≈ 0.01.
Switch to the Inhomogeneous K-Function for Non-Stationary Processes
When the kernel density intensity surface shows strong gradients (e.g., higher event density near roads, rivers, or population centres), the homogeneous K-function will report spurious clustering even if no genuine inter-point interaction exists. The inhomogeneous K-function normalises by local intensity rather than global area. The PySAL pointpats library provides pointpats.Kest with inhomogeneous weighting. See also Stationarity & Trend Analysis for tests that help decide whether a homogeneous or inhomogeneous model is appropriate.
Scale Performance for Large Datasets
The per-point inner loop above runs in O(N × M) time where M is the number of distance bands. For N > 5,000:
- Replace the per-simulation
query_ball_pointloop withtree.query_ball_tree(sim_tree, r=d)to get all pairs in one call. - For rectangular windows, replace the Shapely
circle.intersection(window)with a closed-form rectangle–circle overlap formula (constant time per point, no geometry construction). - Parallelise across distance bands with
joblib.Parallel(n_jobs=-1)— each band is independent.
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
k_obs everywhere above the CSR envelope, even at d=1 |
Window too large relative to the actual event domain | Tighten window_polygon to the true observation area |
k_obs below the theoretical πd² at all scales |
Geographic CRS used instead of projected | Reproject to a planar CRS before running |
| CSR envelope extremely wide | N < 30 — insufficient points for stable K estimation | Consider quadrat methods or nearest-neighbour statistics instead |
clipped_area consistently near zero for boundary points |
Window polygon uses a very different scale/units from coords | Confirm both are in the same CRS and unit |
| Simulation runs very slowly (>10 min) | Rejection sampling inefficient for complex window shapes | Pre-compute a rasterised mask of the window and sample from valid pixels |
| L-function shows clustering at all scales with no plateau | Inhomogeneous first-order intensity not accounted for | Switch to the inhomogeneous K-function or detrend the intensity surface first |
Next Steps
For a broader treatment of distance-based and quadrat statistics, return to the Point Pattern Analysis workflow page. If your study dataset has known spatial sampling bias — for example, opportunistic species records or mobile sensor pings — read Correcting Spatial Sampling Bias with GeoPandas before running K-function tests, since unweighted biased data will inflate clustering signals.
Related
- Point Pattern Analysis — Python Workflow
- How to Calculate Moran’s I in PySAL
- Correcting Spatial Sampling Bias with GeoPandas
← Back to Point Pattern Analysis