Using the Cross-K Function for Case-Control Data
This guide, part of K-Function & Point Pattern Analysis, covers the version of Ripley’s K that actually answers an epidemiological question: are cases more clustered than the population they come from?
Problem Context & Constraints
A univariate K-function applied to geocoded cases almost always shows strong clustering. That result is nearly worthless, because people cluster: they live in towns, along roads, in apartment blocks. Cases inherit that clustering from the population whether or not disease risk varies in space. The comparison of cases against complete spatial randomness asks the wrong question.
Case-control point data fix the reference. Controls — people without the disease, sampled from the same population, or a proxy such as births, residential addresses or cases of an unrelated condition — show how the population is distributed. Two related tools then compare the patterns. The K-function difference, D® = K_cases® − K_controls®, asks whether cases are more clustered at distance r than controls are. The cross-K function, K_cc®, counts controls within distance r of each case and asks whether cases and controls are more or less mixed than chance predicts.
Inference relies on random labelling. Under the null hypothesis that disease risk does not vary in space, the case and control labels are exchangeable: any point is equally likely to be a case. Permuting labels across the fixed set of locations and recomputing D® gives a simulation envelope that already accounts for the clustering of population — the property that makes this approach sound where comparison with complete spatial randomness is not.
Prerequisites
python3.11numpy1.26.4,scipy1.13.1,geopandas0.14.4,shapely2.0.4- Geocoded cases and controls in a projected metric coordinate reference system, with a label column
- A study-region polygon for edge correction
- A control selection design documented well enough to say what population the controls represent
- Familiarity with univariate K and edge correction from Edge Correction Choices for County Boundaries
Step-by-Step Solution
Compute K for cases and controls with the same edge correction, take the difference, then permute labels to build a pointwise envelope and a global test that does not depend on picking one distance after looking.
# K-function difference and cross-K with random-labelling envelopes.
# Pinned: numpy==1.26.4, scipy==1.13.1, geopandas==0.14.4, shapely==2.0.4
import logging
import numpy as np
import geopandas as gpd
from scipy.spatial import cKDTree
from shapely.geometry import Point
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("crossk")
SEED = 20260918
def border_weights(xy: np.ndarray, region, r: np.ndarray) -> np.ndarray:
"""Border-method edge correction: a point counts at distance r only if it is
at least r from the region boundary. Returns an (n, len(r)) mask."""
boundary = region.boundary
d_edge = np.array([boundary.distance(Point(p)) for p in xy])
return d_edge[:, None] >= r[None, :]
def k_hat(from_xy, to_xy, region, r, same: bool, d_edge_mask) -> np.ndarray:
"""K estimate from `from` points to `to` points, border-corrected."""
area = region.area
tree = cKDTree(to_xy)
k = np.zeros(len(r))
for j, rr in enumerate(r):
keep = d_edge_mask[:, j]
if not keep.any():
k[j] = np.nan
continue
counts = np.array([len(tree.query_ball_point(p, rr)) for p in from_xy[keep]])
if same:
counts = counts - 1 # exclude the point itself
lam_to = len(to_xy) / area
k[j] = counts.mean() / lam_to
return k
def k_difference(xy, is_case, region, r):
mask = border_weights(xy, region, r)
c, n = xy[is_case], xy[~is_case]
kc = k_hat(c, c, region, r, True, mask[is_case])
kn = k_hat(n, n, region, r, True, mask[~is_case])
return kc - kn
def random_labelling(xy, is_case, region, r, n_sim: int = 199, seed: int = SEED):
rng = np.random.default_rng(seed)
observed = k_difference(xy, is_case, region, r)
sims = np.empty((n_sim, len(r)))
for s in range(n_sim):
sims[s] = k_difference(xy, rng.permutation(is_case), region, r)
# Global test: standardised squared deviation summed over distances.
sd = sims.std(axis=0, ddof=1)
sd[sd == 0] = np.nan
stat = lambda d: np.nansum((d / sd) ** 2)
obs_stat = stat(observed)
sim_stats = np.array([stat(s) for s in sims])
p_global = (1 + (sim_stats >= obs_stat).sum()) / (n_sim + 1)
lo, hi = np.nanpercentile(sims, [2.5, 97.5], axis=0)
log.info("n_cases=%d n_controls=%d sims=%d global p=%.3f",
int(is_case.sum()), int((~is_case).sum()), n_sim, p_global)
return observed, lo, hi, p_global
The global test matters. A pointwise envelope evaluated at 30 distances will be crossed somewhere by chance far more often than 5 percent of the time. The standardised squared-deviation statistic summarises the whole curve in one number, tested against the same permutations, so the headline answer — are cases clustered relative to controls at all? — has a valid p-value. The pointwise envelope then shows at which distances the departure occurs.
Cross-K follows the same pattern with k_hat(cases, controls, ...). A cross-K below its random-labelling envelope means cases have fewer controls nearby than expected — they are segregated from the control population — which is another way of expressing case clustering relative to population. For most surveillance questions, D® is easier to explain, and cross-K is a useful confirmation. When the two disagree — D® positive while cross-K sits inside its envelope — the usual cause is a small number of tight case groups too few to shift how cases mix with controls overall; look at those groups directly.
Validation & Edge Cases
Controls that do not represent the population at risk. The whole method rests on controls being a fair sample of where the population lives. Controls drawn from one hospital’s patients over-represent its catchment; birth records under-represent older adults. A mismatched control pattern produces spurious D® departures in either direction. Document the control frame and check it against census population by tract before interpreting.
Unequal case and control counts. D® is valid with different numbers of cases and controls, but precision depends on the smaller group. A study with 40 cases and 4,000 controls has a narrow control K and a noisy case K. Report both counts, and prefer a control-to-case ratio of 2 to 5 unless controls are cheap and the analysis is dominated by the case count anyway.
Duplicate locations. Geocoding to parcel or address-point centroids can put several people at the same coordinates — apartment buildings, care homes. Identical points inflate K at distance zero. Keep duplicates, since they are real co-location, but check whether one address dominates; an outbreak in a single care home is better handled as an institutional investigation than as spatial clustering.
Distance range. Evaluate r only up to about a quarter of the shorter side of the study region. Beyond that, border correction discards most points and the estimate becomes unstable.
Masked coordinates. If locations were geomasked before analysis, the displacement blurs short-distance structure; clustering below the masking radius cannot be detected. State the masking distance and interpret D® only above it.
Inhomogeneous risk versus interaction. A positive D® says cases are more clustered than controls; it does not say whether that is because risk varies smoothly in space or because cases cause cases. Distinguishing the two needs a model — an inhomogeneous K with a fitted risk surface, or a space-time analysis.
Too few permutations near the envelope edge. With 199 permutations the envelope edges are set by the fifth-highest and fifth-lowest simulated values, which move noticeably between runs. For a result near the edge, rerun with 999 permutations before reporting, and fix the seed so the reported envelope is reproducible.
INFO crossk n_cases=212 n_controls=636 sims=199 global p=0.006
INFO crossk D(r) above envelope for r in [200, 1500] m; max at r=600 m
Compliance Notes
Record the control selection design, case and control counts, the coordinate reference system, the edge correction, the distance grid, the number of permutations and seed, the global p-value, and any geomasking applied before analysis.
Case-control point data are among the most sensitive data a health department holds: each point is a person’s home linked to a diagnosis. Run the analysis inside the secure environment, publish only the D® curve, envelope and summary statistics — never point maps — and check that the distance grid’s shortest values do not, in combination with small counts, reveal individual locations.
Related Topics
- K-Function & Point Pattern Analysis — the parent guide.
- Implementing Ripley’s K-Function in Python for Vector-Borne Diseases — the univariate foundation.
- Estimating a Spatial Relative Risk Surface — mapping where the case excess is, from the same case-control data.