BallKFold

BallKFold assigns observations to folds such that no two test points in the same fold are within radius r of each other — each test set is a spatial independent set in a conflict graph whose edges connect pairs within r.

This prevents spatial autocorrelation leakage within test folds: a model cannot “cheat” by interpolating between spatially adjacent test points, because none exist.

How it works

  1. Build a conflict graph: add an edge between every pair of points within r.

  2. Greedily colour the graph (largest-degree-first): each colour class becomes one fold’s test set.

  3. For each fold, train on all points not in that fold; evaluate on the fold.

Two modes

  • radius=r — explicit exclusion radius in coordinate units.

  • n_splits=k — target number of folds; the implied radius is the minimum k-th nearest-neighbour distance across all points (guaranteeing max degree ≤ k − 1 in the conflict graph, so greedy colouring uses ≤ k colours).

Contrast with HilbertKFold

HilbertKFold

BallKFold

Test-set property

each fold covers the whole study area

each fold’s points are ≥ r apart

Guard against

spatially biased train/test splits

intra-fold autocorrelation leakage

Fold count

fixed by n_splits

determined by graph colouring

Fold sizes

approximately equal

first fold largest; decreasing

import numpy
import geopandas
import geodatasets
import colormaps
import matplotlib.pyplot as plt
from scipy.spatial.distance import cdist
from sklearn.model_selection import KFold
from sklearn.linear_model import LinearRegression
from sklearn.metrics import root_mean_squared_error

from geovalidate import BallKFold
/Users/lw17329/miniforge/envs/geovalidate/lib/python3.11/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
  from .autonotebook import tqdm as notebook_tqdm

Data — King County house sales

We work with a stratified subsample of 600 sales projected to UTM 10N (metres). Stratifying by log-price decile keeps the price distribution representative.

gdf_full = geopandas.read_file(geodatasets.get_path("geoda.home_sales")).to_crs("EPSG:32610")
gdf_full["log_price"] = numpy.log(gdf_full["price"])
gdf_full["decile"] = (
    gdf_full["log_price"].rank(pct=True).multiply(10).clip(upper=9.99).astype(int)
)
idx = (
    gdf_full.groupby("decile")
    .apply(lambda g: g.sample(min(60, len(g)), random_state=42), include_groups=False)
    .index.get_level_values(1)
)
gdf = gdf_full.loc[idx].reset_index(drop=True)
coords = numpy.column_stack([gdf.geometry.x, gdf.geometry.y])
print(f"n = {len(gdf)} sales  |  price ${gdf.price.min():,.0f} – ${gdf.price.max():,.0f}")
n = 600 sales  |  price $84,000 – $3,168,750

n_splits mode

With n_splits=k, the exclusion radius is set to the minimum k-th nearest-neighbour distance across all points — stored as radius_ after the first split() call. The actual number of folds produced can be less than k if the conflict graph turns out to be sparse enough to colour with fewer colours.

bkf_nf = BallKFold(n_splits=5)
splits_nf = list(bkf_nf.split(coords))

print(f"n_splits=5  →  actual folds: {bkf_nf.n_splits_}")
print(f"implied radius:             {bkf_nf.radius_/1000:.2f} km")
print(f"fold sizes:                 {sorted([len(t) for _, t in splits_nf], reverse=True)}")
n_splits=5  →  actual folds: 4
implied radius:             0.42 km
fold sizes:                 [488, 97, 13, 2]

radius mode

Supplying an explicit radius gives direct control over the exclusion distance. A 2 km radius produces folds of broadly similar size that are each a clean spatially independent test set.

RADIUS_M = 2_000  # 2 km

bkf = BallKFold(radius=RADIUS_M)
splits = list(bkf.split(coords))

print(f"radius=2 km  →  {bkf.n_splits_} folds")
sizes = sorted([len(t) for _, t in splits], reverse=True)
print(f"fold sizes (sorted): {sizes}")
radius=2 km  →  14 folds
fold sizes (sorted): [152, 123, 102, 76, 54, 39, 26, 13, 5, 3, 3, 2, 1, 1]

Fold maps

The first six folds from BallKFold(radius=2 km) are shown on the left; a random KFold with the same number of splits is shown on the right for comparison.

In the BallKFold panel, each colour is a test set — no two same-coloured points are closer than 2 km. The random KFold has no such guarantee.

N_SHOW = 6  # only plot first N_SHOW folds for readability

ball_labels = numpy.full(len(gdf), -1, dtype=int)
for fi, (_, test) in enumerate(splits):
    ball_labels[test] = fi

rkf = KFold(n_splits=bkf.n_splits_, shuffle=True, random_state=0)
rand_labels = numpy.full(len(gdf), -1, dtype=int)
for fi, (_, test) in enumerate(rkf.split(coords)):
    rand_labels[test] = fi

gdf["ball_fold"] = ball_labels
gdf["rand_fold"] = rand_labels

# keep only the first N_SHOW colours for the map; grey out the rest
ball_show = numpy.where(ball_labels < N_SHOW, ball_labels, -1)
rand_show = numpy.where(rand_labels < N_SHOW, rand_labels, -1)
gdf["ball_show"] = ball_show
gdf["rand_show"] = rand_show

cmap = colormaps.safe
fold_colors = [cmap(i / max(N_SHOW - 1, 1)) for i in range(N_SHOW)]

fig, axes = plt.subplots(1, 2, figsize=(16, 7))

for ax, col, title in [
    (axes[0], "ball_show", f"BallKFold (r = 2 km) — first {N_SHOW} of {bkf.n_splits_} folds"),
    (axes[1], "rand_show", f"Random KFold ({bkf.n_splits_} splits) — first {N_SHOW} shown"),
]:
    # grey background: points not in the first N_SHOW folds
    gdf[gdf[col] == -1].plot(ax=ax, color="#ccc", markersize=2, alpha=0.5)
    for fi in range(N_SHOW):
        sub = gdf[gdf[col] == fi]
        sub.plot(ax=ax, color=fold_colors[fi], markersize=6, alpha=0.8,
                 label=f"fold {fi}  (n={len(sub)})")
    ax.set_title(title, fontsize=11)
    ax.set_xlabel("Easting (m)"); ax.set_ylabel("Northing (m)")
    ax.set_aspect("equal")
    ax.legend(fontsize=8, markerscale=2, loc="lower right")

fig.suptitle("BallKFold vs Random KFold — King County house sales (UTM 10N)", fontsize=13)
fig.tight_layout()
plt.show()
../_images/d9a5544b74b2968c6f8d039a0a671845493d94adc85201e44fc34a108a3bf538.png

The exclusion guarantee

For every fold, the minimum pairwise distance among test points must be ≥ r. This is a hard structural guarantee from the graph colouring, not a probabilistic one.

r = float(RADIUS_M)
print(f"Exclusion radius: {r/1000:.1f} km\n")
print(f"{'Fold':>5}  {'n_test':>7}  {'min dist (km)':>15}  {'≥ r':>5}")
print("-" * 40)
for fi, (_, test) in enumerate(splits):
    c = coords[test]
    if len(c) == 1:
        print(f"{fi:>5}  {len(test):>7}  {'(singleton)':>15}  {'✓':>5}")
        continue
    D = cdist(c, c)
    numpy.fill_diagonal(D, numpy.inf)
    min_d = D.min()
    ok = "✓" if min_d + 1e-6 >= r else "✗"
    print(f"{fi:>5}  {len(test):>7}  {min_d/1000:>15.3f}  {ok:>5}")
Exclusion radius: 2.0 km

 Fold   n_test    min dist (km)    ≥ r
----------------------------------------
    0      152            2.019      ✓
    1      123            2.002      ✓
    2      102            2.008      ✓
    3       76            2.009      ✓
    4       54            2.018      ✓
    5       39            2.021      ✓
    6       26            2.006      ✓
    7       13            2.038      ✓
    8        5            2.515      ✓
    9        3            2.086      ✓
   10        3            2.003      ✓
   11        2            2.886      ✓
   12        1      (singleton)      ✓
   13        1      (singleton)      ✓

Cross-validation example

BallKFold.split() follows the standard sklearn cross-validator protocol. We predict log(price) from structural features and compare RMSE per fold against a random KFold with the same number of splits.

feat_cols = ["sqft_liv", "bedrooms", "bathrooms", "grade"]
y = numpy.log(gdf["price"].values)
X = gdf[feat_cols].fillna(0).values

ball_rmse, rand_rmse = [], []
for train, test in splits:
    lr = LinearRegression().fit(X[train], y[train])
    ball_rmse.append(root_mean_squared_error(y[test], lr.predict(X[test])))
for train, test in rkf.split(X, y):
    lr = LinearRegression().fit(X[train], y[train])
    rand_rmse.append(root_mean_squared_error(y[test], lr.predict(X[test])))

print(f"BallKFold  — {len(ball_rmse)} folds, RMSE mean={numpy.mean(ball_rmse):.3f}  std={numpy.std(ball_rmse):.3f}")
print(f"Random KFold — {len(rand_rmse)} folds, RMSE mean={numpy.mean(rand_rmse):.3f}  std={numpy.std(rand_rmse):.3f}")
BallKFold  — 14 folds, RMSE mean=0.364  std=0.081
Random KFold — 14 folds, RMSE mean=0.363  std=0.030
c_ball = colormaps.safe(0.15)
c_rand = colormaps.safe(0.75)

fig, axes = plt.subplots(1, 2, figsize=(16, 5))

# RMSE per fold
ax = axes[0]
ax.plot(ball_rmse, "o-", color=c_ball, label=f"BallKFold  (mean {numpy.mean(ball_rmse):.3f})", zorder=3)
ax.plot(rand_rmse, "s--", color=c_rand, label=f"Random KFold (mean {numpy.mean(rand_rmse):.3f})", zorder=3)
ax.axhline(numpy.mean(ball_rmse), color=c_ball, lw=1, ls=":", alpha=0.7)
ax.axhline(numpy.mean(rand_rmse), color=c_rand, lw=1, ls=":", alpha=0.7)
ax.set_xlabel("Fold index")
ax.set_ylabel("RMSE  (log price)")
ax.set_title("Per-fold RMSE")
ax.legend(fontsize=9)
ax.yaxis.grid(True, alpha=0.4)
ax.set_axisbelow(True)

# fold sizes
ax2 = axes[1]
ax2.plot([len(t) for _, t in splits], "o-", color=c_ball,
         label="BallKFold  (r = 2 km)", zorder=3)
ax2.plot([len(t) for _, t in rkf.split(X, y)], "s--", color=c_rand,
         label="Random KFold", zorder=3)
ax2.set_xlabel("Fold index")
ax2.set_ylabel("Test-set size")
ax2.set_title("Test-set size per fold")
ax2.legend(fontsize=9)
ax2.yaxis.grid(True, alpha=0.4)
ax2.set_axisbelow(True)

fig.suptitle("BallKFold vs Random KFold — log(price) ~ sqft + beds + baths + grade", fontsize=12)
fig.tight_layout()
plt.show()
../_images/36fd215867495cc767b0085ae2ca211b0c53b0f775040dc919dee84810b2a092.png