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.

The Right Reference Pattern Two panels. On the left, cases are compared with complete spatial randomness: cases cluster in two towns and the test reports strong clustering, but the controls, shown faintly, cluster in exactly the same towns, so the result only reflects where people live. On the right, cases are compared with controls: in one town cases are concentrated in a small neighbourhood relative to controls, and only that relative excess is reported as clustering. Clustering relative to what? Cases vs randomness "clustered" — because people are Cases vs controls excess only where cases outpace controls filled points are cases, open points are controls

Prerequisites

  • python 3.11
  • numpy 1.26.4, scipy 1.13.1, geopandas 0.14.4, shapely 2.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.

Reading D(r) Against Its Envelope A plot of the K-function difference against distance. A shaded envelope from random labelling surrounds zero, widening with distance. The observed curve rises above the envelope between about two hundred metres and one point five kilometres, showing cases are more clustered than controls at those distances, and returns inside the envelope at longer distances. A note records the global test p-value, which confirms the departure is not a chance crossing at one distance. Case clustering beyond population clustering D(r) = 0 above envelope: 0.2–1.5 km distance r global p = 0.006 shaded band: 95% pointwise envelope from 199 random labellings

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
Case-Control K in Four Steps Four boxes in sequence. Check controls compares the control frame with census population. Compute D of r estimates the case and control K-functions with the same border correction and takes the difference. Random labelling permutes labels to build a pointwise envelope and a global test. Report states the global p-value, the distance range above the envelope, and the control frame used. From points to a defensible result Check controls vs census population Compute D(r) same edge correction Random labelling envelope + global test Report p, distance range control frame the control frame is part of the result, not a footnote

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.