LeaveClusterOut

LeaveClusterOut implements spatial block cross-validation using a user-supplied clustering estimator. Each fold holds out one spatial cluster as the test set; the training set is all observations in every other cluster.

This is the leave-one-block-out analogue of ClusterStratifiedKFold. Unlike DGGS-based blocking, the cluster shapes adapt to the spatial density of the data. HDBSCAN also produces noise points (label = -1) — observations too isolated to belong to any cluster — and LeaveClusterOut offers three ways to handle them:

  • train_only (default): noise points appear in every training set, never test.

  • drop: noise points are excluded from both train and test.

  • nearest: each noise point is reassigned to the cluster of its nearest non-noise neighbour and then participates in that cluster’s fold as a regular member.

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 sklearn.cluster import HDBSCAN

from geovalidate.cv import LeaveClusterOut
/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(str(len(gdf)) + ' house sales')
21613 house sales

Fit HDBSCAN

Using metric='haversine' lets HDBSCAN work directly in geographic space without reprojecting. Haversine expects coordinates as (lat, lon) in radians.

We pre-fit the clusterer and pass it to LeaveClusterOut. When labels_ is already present on the estimator, split() uses those labels directly and does not refit — useful for comparing noise options without redundant work.

The scan below finds the largest min_cluster_size that still yields at least 8 clusters.

# haversine expects (lat, lon) in radians
coords_rad = np.radians(np.column_stack([gdf.geometry.y, gdf.geometry.x]))

best_mcs = 100
for mcs in [100, 150, 200, 250, 300, 400, 500]:
    labels_ = HDBSCAN(min_cluster_size=mcs, metric='haversine').fit(coords_rad).labels_
    n = (np.unique(labels_) >= 0).sum()
    n_noise = (labels_ == -1).sum()
    print('  min_cluster_size=' + str(mcs) + ' -> ' + str(n) + ' clusters, '
          + str(n_noise) + ' noise (' + str(round(100 * n_noise / len(labels_), 1)) + '%)')
    if n >= 8:
        best_mcs = mcs

print('\nUsing min_cluster_size=' + str(best_mcs))
hdb = HDBSCAN(min_cluster_size=best_mcs, metric='haversine').fit(coords_rad)
  min_cluster_size=100 -> 5 clusters, 1018 noise (4.7%)
  min_cluster_size=150 -> 3 clusters, 1128 noise (5.2%)
  min_cluster_size=200 -> 2 clusters, 1502 noise (6.9%)
  min_cluster_size=250 -> 2 clusters, 1891 noise (8.7%)
  min_cluster_size=300 -> 8 clusters, 11557 noise (53.5%)
  min_cluster_size=400 -> 6 clusters, 11581 noise (53.6%)
  min_cluster_size=500 -> 3 clusters, 6594 noise (30.5%)

Using min_cluster_size=300
/Users/ljwolf/miniforge3/envs/analysis/lib/python3.14/site-packages/sklearn/cluster/_hdbscan/hdbscan.py:722: FutureWarning: The default value of `copy` will change from False to True in 1.10. Explicitly set a value for `copy` to silence this warning.
  warn(
/Users/ljwolf/miniforge3/envs/analysis/lib/python3.14/site-packages/sklearn/cluster/_hdbscan/hdbscan.py:722: FutureWarning: The default value of `copy` will change from False to True in 1.10. Explicitly set a value for `copy` to silence this warning.
  warn(
/Users/ljwolf/miniforge3/envs/analysis/lib/python3.14/site-packages/sklearn/cluster/_hdbscan/hdbscan.py:722: FutureWarning: The default value of `copy` will change from False to True in 1.10. Explicitly set a value for `copy` to silence this warning.
  warn(
/Users/ljwolf/miniforge3/envs/analysis/lib/python3.14/site-packages/sklearn/cluster/_hdbscan/hdbscan.py:722: FutureWarning: The default value of `copy` will change from False to True in 1.10. Explicitly set a value for `copy` to silence this warning.
  warn(
/Users/ljwolf/miniforge3/envs/analysis/lib/python3.14/site-packages/sklearn/cluster/_hdbscan/hdbscan.py:722: FutureWarning: The default value of `copy` will change from False to True in 1.10. Explicitly set a value for `copy` to silence this warning.
  warn(
/Users/ljwolf/miniforge3/envs/analysis/lib/python3.14/site-packages/sklearn/cluster/_hdbscan/hdbscan.py:722: FutureWarning: The default value of `copy` will change from False to True in 1.10. Explicitly set a value for `copy` to silence this warning.
  warn(
/Users/ljwolf/miniforge3/envs/analysis/lib/python3.14/site-packages/sklearn/cluster/_hdbscan/hdbscan.py:722: FutureWarning: The default value of `copy` will change from False to True in 1.10. Explicitly set a value for `copy` to silence this warning.
  warn(
/Users/ljwolf/miniforge3/envs/analysis/lib/python3.14/site-packages/sklearn/cluster/_hdbscan/hdbscan.py:722: FutureWarning: The default value of `copy` will change from False to True in 1.10. Explicitly set a value for `copy` to silence this warning.
  warn(

Cluster assignments

# Pass the pre-fitted HDBSCAN; LeaveClusterOut detects labels_ and skips refitting
lco = LeaveClusterOut(hdb, noise='train_only')
splits_to = list(lco.split(gdf))

labels = lco.labels_
n_noise = (labels == -1).sum()
print('Clusters (folds) : ' + str(lco.n_clusters_))
print('Noise points     : ' + str(n_noise) + ' (' + str(round(100 * n_noise / len(gdf), 1)) + '%)')
Clusters (folds) : 8
Noise points     : 11557 (53.5%)
/Users/ljwolf/Dropbox/work/dev/geovalidate/src/geovalidate/cv/_utils.py:58: 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.

  c = X.geometry.centroid
fig, ax = plt.subplots(figsize=(7, 8))
noise_mask = labels == -1

ax.scatter(
    gdf.geometry.x[noise_mask], gdf.geometry.y[noise_mask],
    color='lightgrey', s=0.5, zorder=1,
)
cluster_labels = labels[~noise_mask]
sc = ax.scatter(
    gdf.geometry.x[~noise_mask], gdf.geometry.y[~noise_mask],
    c=cluster_labels, cmap=colormaps.safe[:lco.n_clusters_], s=1.5, zorder=2,
    vmin=0, vmax=lco.n_clusters_ - 1,
)
handles = [
    mpatches.Patch(color=sc.cmap(sc.norm(i)), label='cluster ' + str(i))
    for i in range(lco.n_clusters_)
]
handles.append(mpatches.Patch(color='lightgrey', label='noise (' + str(n_noise) + ')'))
ax.legend(handles=handles, loc='lower left', fontsize=8)
ax.set_title(
    'HDBSCAN -- ' + str(lco.n_clusters_) + ' clusters, '
    + str(n_noise) + ' noise points (min_cluster_size=' + str(best_mcs) + ')'
)
ax.set_xlabel('longitude')
ax.set_ylabel('latitude')
plt.tight_layout()
plt.show()
../_images/ce1a0fa21adec075b8fd179d675c8524dde8e4bc63810110c5f6502e030c10e0.png

Noise point options: train_only, drop, and nearest

Noise points are never the sole subject of a test fold. The noise parameter controls how they participate in each fold:

  • train_only (default): noise points are in every training set. The model sees them during training but is never evaluated on them.

  • drop: noise points are excluded from both train and test. Use this when nearby noise points might leak spatial information about the held-out cluster.

  • nearest: each noise point is reassigned to the cluster of its nearest non-noise neighbour. It then acts as a full member of that cluster — appearing in the test fold when that cluster is held out, and in the training fold otherwise. The original labels (including -1) are preserved in labels_.

Because hdb is already fitted (has labels_), none of the calls below refit.

lco_drop    = LeaveClusterOut(lco.clusterer_, noise='drop')
lco_nearest = LeaveClusterOut(lco.clusterer_, noise='nearest')

splits_drop    = list(lco_drop.split(gdf))
splits_nearest = list(lco_nearest.split(gdf))

stats = []
for i, ((tr_to, te), (tr_dr, _), (tr_nr, _)) in enumerate(
    zip(splits_to, splits_drop, splits_nearest)
):
    stats.append({
        'fold': i,
        'test size': len(te),
        'train (train_only)': len(tr_to),
        'train (drop)': len(tr_dr),
        'train (nearest)': len(tr_nr),
        'noise excluded (drop)': len(tr_to) - len(tr_dr),
    })
pd.DataFrame(stats)
fold test size train (train_only) train (drop) train (nearest) noise excluded (drop)
0 0 1017 20596 9039 19665 11557
1 1 607 21006 9449 20194 11557
2 2 1687 19926 8369 19349 11557
3 3 472 21141 9584 18708 11557
4 4 1237 20376 8819 17405 11557
5 5 711 20902 9345 19171 11557
6 6 1119 20494 8937 19673 11557
7 7 3206 18407 6850 17126 11557

Noise handling visualised

The three panels below show the same fold under each noise option. Orange points on the left are the noise points that train_only keeps in training. In the nearest panel, noise points assigned to the held-out cluster appear in the test set (red) alongside the regular cluster members.

fold = 0
tr_to, te     = splits_to[fold]
tr_dr, _      = splits_drop[fold]
tr_nr, te_nr  = splits_nearest[fold]

noise_in_train   = np.setdiff1d(tr_to, tr_dr)          # noise visible in train_only
noise_in_test_nr = np.intersect1d(te_nr, np.where(lco.labels_ == -1)[0])  # noise -> test in nearest

fig, axes = plt.subplots(1, 3, figsize=(18, 6), sharex=True, sharey=True)

configs = [
    (tr_to, te,    "noise='train_only'"),
    (tr_dr, te,    "noise='drop'"),
    (tr_nr, te_nr, "noise='nearest'"),
]

for ax, (train, test, title) in zip(axes, configs):
    ax.scatter(
        gdf.geometry.x.iloc[train], gdf.geometry.y.iloc[train],
        color='steelblue', s=0.4, alpha=0.4,
    )
    ax.scatter(
        gdf.geometry.x.iloc[test], gdf.geometry.y.iloc[test],
        color='firebrick', s=3, zorder=3,
    )
    ax.set_title(title, fontsize=12)
    ax.set_axis_off()

# highlight noise points on the train_only panel
axes[0].scatter(
    gdf.geometry.x.iloc[noise_in_train], gdf.geometry.y.iloc[noise_in_train],
    color='orange', s=1.5, zorder=2, alpha=0.8,
)

# highlight reassigned noise in the nearest panel (split between test and train)
noise_in_train_nr = np.intersect1d(tr_nr, np.where(lco.labels_ == -1)[0])
axes[2].scatter(
    gdf.geometry.x.iloc[noise_in_test_nr], gdf.geometry.y.iloc[noise_in_test_nr],
    color='darkorange', s=4, zorder=4, alpha=0.9,
)
axes[2].scatter(
    gdf.geometry.x.iloc[noise_in_train_nr], gdf.geometry.y.iloc[noise_in_train_nr],
    color='orange', s=1.5, zorder=2, alpha=0.8,
)

train_patch      = mpatches.Patch(color='steelblue',  label='train (cluster pts)')
test_patch       = mpatches.Patch(color='firebrick',  label='test cluster')
noise_train_patch = mpatches.Patch(color='orange',    label='noise -> train')
noise_test_patch  = mpatches.Patch(color='darkorange', label='noise -> test (nearest)')

axes[0].legend(handles=[train_patch, test_patch, noise_train_patch], loc='lower left', fontsize=8)
axes[1].legend(handles=[train_patch, test_patch], loc='lower left', fontsize=8)
axes[2].legend(handles=[train_patch, test_patch, noise_train_patch, noise_test_patch],
               loc='lower left', fontsize=8)

fig.suptitle('LeaveClusterOut fold ' + str(fold) + ': noise handling comparison', fontsize=13)
plt.tight_layout()
plt.show()
../_images/c8a683921d3e7dc85dc6a8ba399e455e1633eb0ac4ce40fd0c599bbc2718ebcf.png