LeaveCellOut

LeaveCellOut implements spatial block cross-validation using a discrete global grid system (DGGS). Each fold holds out one grid cell as the test set; the training set is all observations in every other cell.

This is the leave-one-block-out analogue of CellStratifiedKFold, which distributes each cell’s observations across all folds instead of holding them out whole. Use LeaveCellOut when you want to evaluate how well a model generalises to an entirely unseen geographic region.

import colormaps
import numpy as np
import pandas as pd
import geopandas as gpd
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches

from geovalidate.cv import LeaveCellOut
/Users/ljwolf/miniforge3/envs/analysis/lib/python3.14/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_PATH = '/Users/ljwolf/Dropbox/work/projects/spquadtree/code/kc_house_data.csv'

df = pd.read_csv(DATA_PATH)
gdf = gpd.GeoDataFrame(
    df,
    geometry=gpd.points_from_xy(df['long'], df['lat']),
    crs='EPSG:4326',
)
print(f'{len(gdf):,} house sales, '
      f'lat {gdf.geometry.y.min():.3f}-{gdf.geometry.y.max():.3f}, '
      f'lon {gdf.geometry.x.min():.3f}-{gdf.geometry.x.max():.3f}')
21,613 house sales, lat 47.156-47.778, lon -122.519--121.315

H3 grid

H3 resolution 4 yields 7 occupied cells over King County. Increase the resolution for finer-grained blocks (resolution 5 gives ~26 cells over this dataset). Use min_test_size to exclude cells with very few observations from serving as test folds.

cv = LeaveCellOut(grid='h3', resolution=4)
splits = list(cv.split(gdf))
print('Resolution :', cv.resolution_)
print('Test folds :', cv.n_cells_, '(one per occupied cell)')
Resolution : 4
Test folds : 7 (one per occupied cell)
/Users/ljwolf/Dropbox/work/dev/geovalidate/src/geovalidate/cv/_cell_stratified_kfold.py:28: UserWarning: Geometry is in a geographic CRS. Results from 'centroid' are likely incorrect. Use 'GeoSeries.to_crs()' to re-project geometries to a projected CRS before this operation.

  pts = geom.centroid

Cell assignments

fig, ax = plt.subplots(figsize=(7, 8))
unique_cells, cell_int = np.unique(cv.cell_ids_, return_inverse=True)
sc = ax.scatter(
    gdf.geometry.x, gdf.geometry.y,
    c=cell_int, cmap=colormaps.safe[:len(unique_cells)], s=1, linewidths=0,
)
handles = [
    mpatches.Patch(color=sc.cmap(sc.norm(i)), label='cell ' + str(i))
    for i in range(len(unique_cells))
]
ax.legend(handles=handles, loc='lower left', fontsize=8, title='H3 cell')
ax.set_title('H3 cell assignments -- resolution ' + str(cv.resolution_) + ', ' + str(cv.n_cells_) + ' cells')
ax.set_xlabel('longitude')
ax.set_ylabel('latitude')
plt.tight_layout()
plt.show()
../_images/016bfa1501fa831ab889292cec457e88b9dc8e6cbc63a6375b817d5970310c03.png

Fold sizes

pd.DataFrame(
    [{'fold': i, 'test size': len(te), 'train size': len(tr)}
     for i, (tr, te) in enumerate(splits)]
)
fold test size train size
0 0 251 21362
1 1 1 21612
2 2 2576 19037
3 3 12 21601
4 4 189 21424
5 5 15223 6390
6 6 3361 18252

All folds

ncols = 4
nrows = int(np.ceil(cv.n_cells_ / ncols))
fig, axes = plt.subplots(nrows, ncols, figsize=(4 * ncols, 4 * nrows))
axes = axes.flat

for i, (train, test) in enumerate(splits):
    ax = axes[i]
    ax.scatter(
        gdf.geometry.x.iloc[train], gdf.geometry.y.iloc[train],
        color='steelblue', s=0.3, alpha=0.4,
    )
    ax.scatter(
        gdf.geometry.x.iloc[test], gdf.geometry.y.iloc[test],
        color='firebrick', s=2,
    )
    ax.set_title('Fold ' + str(i) + '  (test n=' + str(len(test)) + ')', fontsize=9)
    ax.set_axis_off()

for ax in list(axes)[cv.n_cells_:]:
    ax.set_visible(False)

fig.legend(
    handles=[
        mpatches.Patch(color='steelblue', label='train'),
        mpatches.Patch(color='firebrick', label='test'),
    ],
    loc='lower right', fontsize=10,
)
fig.suptitle('LeaveCellOut -- all folds', fontsize=13)
plt.tight_layout()
plt.show()
../_images/bab5d3f6d56605527a79d523be3cf8ce0b7c4f4df818af5a0ec02e02bc9e7f8b.png

A5 grid

The A5 (Icosahedral Snyder Equal Area) grid partitions the globe into equal-area pentagonal cells. Resolutions 0–30 progress from coarsest to finest. Resolution 7 yields 6 occupied cells over King County, comparable to H3 resolution 4.

cv_a5 = LeaveCellOut(grid='a5', resolution=7)
splits_a5 = list(cv_a5.split(gdf))
print('Resolution :', cv_a5.resolution_)
print('Test folds :', cv_a5.n_cells_, '(one per occupied cell)')
Resolution : 7
Test folds : 6 (one per occupied cell)
/Users/ljwolf/Dropbox/work/dev/geovalidate/src/geovalidate/cv/_cell_stratified_kfold.py:28: UserWarning: Geometry is in a geographic CRS. Results from 'centroid' are likely incorrect. Use 'GeoSeries.to_crs()' to re-project geometries to a projected CRS before this operation.

  pts = geom.centroid

Cell assignments

fig, ax = plt.subplots(figsize=(7, 8))
unique_cells, cell_int = np.unique(cv_a5.cell_ids_, return_inverse=True)
sc = ax.scatter(
    gdf.geometry.x, gdf.geometry.y,
    c=cell_int, cmap=colormaps.safe[:len(unique_cells)], s=1, linewidths=0,
)
handles = [
    mpatches.Patch(color=sc.cmap(sc.norm(i)), label='cell ' + str(i))
    for i in range(len(unique_cells))
]
ax.legend(handles=handles, loc='lower left', fontsize=8, title='A5 cell')
ax.set_title('A5 cell assignments -- resolution ' + str(cv_a5.resolution_) + ', ' + str(cv_a5.n_cells_) + ' cells')
ax.set_xlabel('longitude')
ax.set_ylabel('latitude')
plt.tight_layout()
plt.show()
../_images/c97d085457ddc5ba91b14b8caa9af0b75959cefdebb0f36bca353f645c8cbb47.png

All folds

ncols = 4
nrows = int(np.ceil(cv_a5.n_cells_ / ncols))
fig, axes = plt.subplots(nrows, ncols, figsize=(4 * ncols, 4 * nrows))
axes = axes.flat

for i, (train, test) in enumerate(splits_a5):
    ax = axes[i]
    ax.scatter(
        gdf.geometry.x.iloc[train], gdf.geometry.y.iloc[train],
        color='steelblue', s=0.3, alpha=0.4,
    )
    ax.scatter(
        gdf.geometry.x.iloc[test], gdf.geometry.y.iloc[test],
        color='firebrick', s=2,
    )
    ax.set_title('Fold ' + str(i) + '  (test n=' + str(len(test)) + ')', fontsize=9)
    ax.set_axis_off()

for ax in list(axes)[cv_a5.n_cells_:]:
    ax.set_visible(False)

fig.legend(
    handles=[
        mpatches.Patch(color='steelblue', label='train'),
        mpatches.Patch(color='firebrick', label='test'),
    ],
    loc='lower right', fontsize=10,
)
fig.suptitle('LeaveCellOut (A5) -- all folds', fontsize=13)
plt.tight_layout()
plt.show()
../_images/86dcec3f06b2780f3817022a858c635ba102321734ae45ca28cb2894e8979b88.png

HEALPix grid

HEALPix (Hierarchical Equal Area isoLatitude Pixelization) divides the sphere into equal-area pixels arranged in rings of constant latitude. The resolution parameter is log₂(nside), so resolution 7 = nside 128. At this resolution, 7 cells cover King County — matching H3 resolution 4.

cv_hp = LeaveCellOut(grid='healpix', resolution=7)
splits_hp = list(cv_hp.split(gdf))
print('Resolution :', cv_hp.resolution_, f'(nside={1 << cv_hp.resolution_})')
print('Test folds :', cv_hp.n_cells_, '(one per occupied cell)')
Resolution : 7 (nside=128)
Test folds : 7 (one per occupied cell)

Cell assignments

fig, ax = plt.subplots(figsize=(7, 8))
unique_cells, cell_int = np.unique(cv_hp.cell_ids_, return_inverse=True)
sc = ax.scatter(
    gdf.geometry.x, gdf.geometry.y,
    c=cell_int, cmap=colormaps.safe[:len(unique_cells)], s=1, linewidths=0,
)
handles = [
    mpatches.Patch(color=sc.cmap(sc.norm(i)), label='cell ' + str(i))
    for i in range(len(unique_cells))
]
ax.legend(handles=handles, loc='lower left', fontsize=8, title='HEALPix cell')
ax.set_title('HEALPix cell assignments -- resolution ' + str(cv_hp.resolution_) + f' (nside={1 << cv_hp.resolution_}), ' + str(cv_hp.n_cells_) + ' cells')
ax.set_xlabel('longitude')
ax.set_ylabel('latitude')
plt.tight_layout()
plt.show()
../_images/8b3101658458c57378542c04815ba85d2ab33592c8bc045ff0776d6f109b049f.png

All folds

ncols = 4
nrows = int(np.ceil(cv_hp.n_cells_ / ncols))
fig, axes = plt.subplots(nrows, ncols, figsize=(4 * ncols, 4 * nrows))
axes = axes.flat

for i, (train, test) in enumerate(splits_hp):
    ax = axes[i]
    ax.scatter(
        gdf.geometry.x.iloc[train], gdf.geometry.y.iloc[train],
        color='steelblue', s=0.3, alpha=0.4,
    )
    ax.scatter(
        gdf.geometry.x.iloc[test], gdf.geometry.y.iloc[test],
        color='firebrick', s=2,
    )
    ax.set_title('Fold ' + str(i) + '  (test n=' + str(len(test)) + ')', fontsize=9)
    ax.set_axis_off()

for ax in list(axes)[cv_hp.n_cells_:]:
    ax.set_visible(False)

fig.legend(
    handles=[
        mpatches.Patch(color='steelblue', label='train'),
        mpatches.Patch(color='firebrick', label='test'),
    ],
    loc='lower right', fontsize=10,
)
fig.suptitle('LeaveCellOut (HEALPix) -- all folds', fontsize=13)
plt.tight_layout()
plt.show()
../_images/bc3f7d20f866f5af20c1e734925d759d76df4d02d9d9da83c74be87e9a78faff.png