spatialcv 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spatialcv/__init__.py +59 -0
- spatialcv/_base.py +105 -0
- spatialcv/_coords.py +201 -0
- spatialcv/blocks.py +152 -0
- spatialcv/buffer.py +158 -0
- spatialcv/cluster.py +143 -0
- spatialcv/datasets.py +101 -0
- spatialcv/diagnostics.py +151 -0
- spatialcv/nndm.py +470 -0
- spatialcv/plotting.py +83 -0
- spatialcv/py.typed +0 -0
- spatialcv/variogram.py +273 -0
- spatialcv-0.1.0.dist-info/METADATA +311 -0
- spatialcv-0.1.0.dist-info/RECORD +17 -0
- spatialcv-0.1.0.dist-info/WHEEL +5 -0
- spatialcv-0.1.0.dist-info/licenses/LICENSE +21 -0
- spatialcv-0.1.0.dist-info/top_level.txt +1 -0
spatialcv/nndm.py
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""Nearest-neighbour distance matching: NNDM LOO CV and kNNDM CV.
|
|
2
|
+
|
|
3
|
+
Both methods choose cross-validation folds so that the distribution of nearest-neighbour
|
|
4
|
+
distances *from CV test points to their training points* matches the distribution of
|
|
5
|
+
distances *from the prediction locations to the training points*. Random CV is then
|
|
6
|
+
"as hard" as the real prediction task, no harder and no easier.
|
|
7
|
+
|
|
8
|
+
* NNDM: Milà, C., Mateu, J., Pebesma, E., Meyer, H. (2022). Nearest neighbour distance
|
|
9
|
+
matching Leave-One-Out Cross-Validation for map validation. *Methods in Ecology and
|
|
10
|
+
Evolution* 13, 1304-1316. https://doi.org/10.1111/2041-210X.13851
|
|
11
|
+
* kNNDM: Linnenbrink, J., Milà, C., Ludwig, M., Meyer, H. (2024). kNNDM CV: k-fold
|
|
12
|
+
nearest-neighbour distance matching cross-validation for map accuracy estimation.
|
|
13
|
+
*Geoscientific Model Development* 17, 5897-5912.
|
|
14
|
+
https://doi.org/10.5194/gmd-17-5897-2024
|
|
15
|
+
|
|
16
|
+
The implementations follow the reference R code in the CAST package (``nndm()`` and
|
|
17
|
+
``knndm()``, geographical space).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any, Dict, Iterator, List, Optional, Sequence, Tuple
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
from sklearn.cluster import KMeans
|
|
27
|
+
from sklearn.utils import check_random_state
|
|
28
|
+
|
|
29
|
+
from ._base import SpatialSplitter, check_n_splits
|
|
30
|
+
from ._coords import (
|
|
31
|
+
as_coords,
|
|
32
|
+
check_metric,
|
|
33
|
+
nearest_distance,
|
|
34
|
+
nearest_neighbour_loo,
|
|
35
|
+
pairwise_distances,
|
|
36
|
+
to_planar,
|
|
37
|
+
to_unit_sphere,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def wasserstein_distance(u: Any, v: Any) -> float:
|
|
42
|
+
"""First Wasserstein distance between two 1-D samples: the area between their ECDFs.
|
|
43
|
+
|
|
44
|
+
Equals ``scipy.stats.wasserstein_distance`` and the W statistic
|
|
45
|
+
(``twosamples::wass_stat``) used by Linnenbrink et al. (2024).
|
|
46
|
+
"""
|
|
47
|
+
a = np.sort(np.asarray(u, dtype=float).ravel())
|
|
48
|
+
b = np.sort(np.asarray(v, dtype=float).ravel())
|
|
49
|
+
if a.size == 0 or b.size == 0:
|
|
50
|
+
raise ValueError("both samples must be non-empty")
|
|
51
|
+
values = np.sort(np.concatenate([a, b]))
|
|
52
|
+
deltas = np.diff(values)
|
|
53
|
+
cdf_a = np.searchsorted(a, values[:-1], side="right") / a.size
|
|
54
|
+
cdf_b = np.searchsorted(b, values[:-1], side="right") / b.size
|
|
55
|
+
return float(np.sum(np.abs(cdf_a - cdf_b) * deltas))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------------------
|
|
59
|
+
# NNDM LOO
|
|
60
|
+
# ---------------------------------------------------------------------------------------
|
|
61
|
+
@dataclass
|
|
62
|
+
class NNDMResult:
|
|
63
|
+
"""Output of :func:`nndm`.
|
|
64
|
+
|
|
65
|
+
Attributes
|
|
66
|
+
----------
|
|
67
|
+
train_indices : list of ndarray
|
|
68
|
+
Training indices of the LOO split that tests sample ``i``.
|
|
69
|
+
test_indices : list of ndarray
|
|
70
|
+
``[i]`` for every sample.
|
|
71
|
+
Gij : ndarray
|
|
72
|
+
Nearest-neighbour distance from every prediction point to the training points.
|
|
73
|
+
Gj : ndarray
|
|
74
|
+
Nearest-neighbour distance between training points (ordinary LOO).
|
|
75
|
+
Gjstar : ndarray
|
|
76
|
+
Nearest-neighbour distance from each test point to its NNDM training set.
|
|
77
|
+
phi : float
|
|
78
|
+
Distance up to which the distributions were matched.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
train_indices: List[np.ndarray]
|
|
82
|
+
test_indices: List[np.ndarray]
|
|
83
|
+
Gij: np.ndarray
|
|
84
|
+
Gj: np.ndarray
|
|
85
|
+
Gjstar: np.ndarray
|
|
86
|
+
phi: float
|
|
87
|
+
min_train: float
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def W(self) -> float:
|
|
91
|
+
"""Wasserstein distance between ``Gjstar`` and ``Gij``."""
|
|
92
|
+
return wasserstein_distance(self.Gjstar, self.Gij)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def nndm(
|
|
96
|
+
train_coords: Any,
|
|
97
|
+
prediction_coords: Any,
|
|
98
|
+
*,
|
|
99
|
+
phi: Optional[float] = None,
|
|
100
|
+
min_train: float = 0.5,
|
|
101
|
+
metric: str = "euclidean",
|
|
102
|
+
) -> NNDMResult:
|
|
103
|
+
"""Nearest Neighbour Distance Matching LOO folds (Milà et al. 2022).
|
|
104
|
+
|
|
105
|
+
Starting from ordinary LOO, the algorithm walks through the nearest-neighbour
|
|
106
|
+
distances ``r`` in increasing order. Whenever the LOO empirical CDF ``Gj*(r)`` would
|
|
107
|
+
still be at or above the prediction CDF ``Gij(r)`` after removing the nearest
|
|
108
|
+
neighbour of test point ``j``, that neighbour is removed from ``j``'s training set
|
|
109
|
+
(as long as more than ``min_train`` of the data remain). It stops at ``r > phi``.
|
|
110
|
+
|
|
111
|
+
Parameters
|
|
112
|
+
----------
|
|
113
|
+
train_coords : array-like (n, 2)
|
|
114
|
+
Training sample locations.
|
|
115
|
+
prediction_coords : array-like (m, 2)
|
|
116
|
+
Locations where the model will predict (e.g. a regular sample of the area of
|
|
117
|
+
interest). Use a few thousand points.
|
|
118
|
+
phi : float, optional
|
|
119
|
+
Range of spatial autocorrelation; distances beyond it are not matched. Defaults to
|
|
120
|
+
the largest observed distance (CAST's ``phi = "max"``).
|
|
121
|
+
min_train : float, default=0.5
|
|
122
|
+
Minimum proportion of samples each LOO training set must keep, in [0, 1).
|
|
123
|
+
metric : {"euclidean", "haversine"}
|
|
124
|
+
"""
|
|
125
|
+
check_metric(metric)
|
|
126
|
+
tpts = as_coords(train_coords, metric)
|
|
127
|
+
ppts = as_coords(prediction_coords, metric)
|
|
128
|
+
if not 0.0 <= min_train < 1.0:
|
|
129
|
+
raise ValueError(f"min_train must be in [0, 1), got {min_train}")
|
|
130
|
+
n = len(tpts)
|
|
131
|
+
if n < 3:
|
|
132
|
+
raise ValueError("NNDM needs at least three training points")
|
|
133
|
+
if len(ppts) == 0:
|
|
134
|
+
raise ValueError("prediction_coords is empty")
|
|
135
|
+
|
|
136
|
+
tdist = pairwise_distances(tpts, tpts, metric)
|
|
137
|
+
np.fill_diagonal(tdist, np.inf)
|
|
138
|
+
Gij = nearest_distance(ppts, tpts, metric)
|
|
139
|
+
Gij_sorted = np.sort(Gij)
|
|
140
|
+
Gj = tdist.min(axis=1)
|
|
141
|
+
if phi is None:
|
|
142
|
+
finite = tdist[np.isfinite(tdist)]
|
|
143
|
+
phi = float(max(Gij.max(), finite.max()))
|
|
144
|
+
elif not phi > 0:
|
|
145
|
+
raise ValueError(f"phi must be positive, got {phi}")
|
|
146
|
+
active = np.isfinite(tdist)
|
|
147
|
+
Gjstar = Gj.copy()
|
|
148
|
+
|
|
149
|
+
def ecdf_pred(r: float) -> float:
|
|
150
|
+
return float(np.searchsorted(Gij_sorted, r, side="right")) / Gij_sorted.size
|
|
151
|
+
|
|
152
|
+
rmin = float(Gjstar.min())
|
|
153
|
+
jmin = int(np.flatnonzero(Gjstar == rmin)[0])
|
|
154
|
+
while rmin <= phi:
|
|
155
|
+
cond_cdf = (np.count_nonzero(Gjstar <= rmin) - 1) / n >= ecdf_pred(rmin)
|
|
156
|
+
cond_size = np.count_nonzero(active[jmin]) / n > min_train
|
|
157
|
+
if cond_cdf and cond_size:
|
|
158
|
+
kmin = np.flatnonzero(tdist[jmin] == rmin)
|
|
159
|
+
tdist[jmin, kmin] = np.inf
|
|
160
|
+
active[jmin, kmin] = False
|
|
161
|
+
Gjstar[jmin] = tdist[jmin].min()
|
|
162
|
+
rmin = float(Gjstar[Gjstar >= rmin].min())
|
|
163
|
+
elif not np.any(Gjstar > rmin):
|
|
164
|
+
break
|
|
165
|
+
else:
|
|
166
|
+
rmin = float(Gjstar[Gjstar > rmin].min())
|
|
167
|
+
if not np.isfinite(rmin):
|
|
168
|
+
break
|
|
169
|
+
jmin = int(np.flatnonzero(Gjstar == rmin)[0])
|
|
170
|
+
|
|
171
|
+
indices = np.arange(n)
|
|
172
|
+
train = [indices[active[i]] for i in range(n)]
|
|
173
|
+
test = [np.array([i]) for i in range(n)]
|
|
174
|
+
return NNDMResult(train, test, Gij, Gj, Gjstar, float(phi), float(min_train))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class NNDMLeaveOneOut(SpatialSplitter):
|
|
178
|
+
"""Nearest Neighbour Distance Matching leave-one-out CV (Milà et al. 2022).
|
|
179
|
+
|
|
180
|
+
A scikit-learn splitter around :func:`nndm`. Test sets are single samples (so
|
|
181
|
+
``cross_val_predict`` works); training sets drop neighbours until the CV distance
|
|
182
|
+
distribution matches the prediction-to-training distance distribution.
|
|
183
|
+
|
|
184
|
+
Parameters
|
|
185
|
+
----------
|
|
186
|
+
prediction_points : array-like (m, 2)
|
|
187
|
+
Locations where the model will be applied, in the same coordinates as the samples.
|
|
188
|
+
phi : float, optional
|
|
189
|
+
Autocorrelation range; defaults to the maximum distance.
|
|
190
|
+
min_train : float, default=0.5
|
|
191
|
+
coords, coord_columns, metric
|
|
192
|
+
See :class:`SpatialBlockKFold`.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
def __init__(
|
|
196
|
+
self,
|
|
197
|
+
prediction_points: Any,
|
|
198
|
+
*,
|
|
199
|
+
phi: Optional[float] = None,
|
|
200
|
+
min_train: float = 0.5,
|
|
201
|
+
coords: Any = None,
|
|
202
|
+
coord_columns: Optional[Sequence[Any]] = None,
|
|
203
|
+
metric: str = "euclidean",
|
|
204
|
+
) -> None:
|
|
205
|
+
self.prediction_points = prediction_points
|
|
206
|
+
self.phi = phi
|
|
207
|
+
self.min_train = min_train
|
|
208
|
+
self.coords = coords
|
|
209
|
+
self.coord_columns = coord_columns
|
|
210
|
+
self.metric = metric
|
|
211
|
+
|
|
212
|
+
def fit_nndm(self, X: Any = None) -> NNDMResult:
|
|
213
|
+
"""Run the NNDM algorithm and return the full result (distances included)."""
|
|
214
|
+
return nndm(
|
|
215
|
+
self._get_coords(X),
|
|
216
|
+
self.prediction_points,
|
|
217
|
+
phi=self.phi,
|
|
218
|
+
min_train=self.min_train,
|
|
219
|
+
metric=self.metric,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def get_n_splits(self, X: Any = None, y: Any = None, groups: Any = None) -> int:
|
|
223
|
+
return len(self._get_coords(X))
|
|
224
|
+
|
|
225
|
+
def split(
|
|
226
|
+
self, X: Any, y: Any = None, groups: Any = None
|
|
227
|
+
) -> Iterator[Tuple[np.ndarray, np.ndarray]]:
|
|
228
|
+
res = self.fit_nndm(X)
|
|
229
|
+
yield from zip(res.train_indices, res.test_indices)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ---------------------------------------------------------------------------------------
|
|
233
|
+
# kNNDM
|
|
234
|
+
# ---------------------------------------------------------------------------------------
|
|
235
|
+
@dataclass
|
|
236
|
+
class KNNDMResult:
|
|
237
|
+
"""Output of :func:`knndm`.
|
|
238
|
+
|
|
239
|
+
Attributes
|
|
240
|
+
----------
|
|
241
|
+
labels : ndarray (n,)
|
|
242
|
+
Test fold (0 .. n_splits-1) of each training sample.
|
|
243
|
+
Gij, Gj, Gjstar : ndarray
|
|
244
|
+
Prediction-to-training, LOO and kNNDM CV nearest-neighbour distances.
|
|
245
|
+
W : float
|
|
246
|
+
Wasserstein distance between ``Gjstar`` and ``Gij`` of the chosen configuration.
|
|
247
|
+
n_clusters : int or None
|
|
248
|
+
Number of clusters of the chosen configuration (None when random CV was chosen).
|
|
249
|
+
method : str
|
|
250
|
+
``"hierarchical"``, ``"kmeans"`` or ``"random"``.
|
|
251
|
+
W_by_n_clusters : dict
|
|
252
|
+
W statistic of every candidate that satisfied ``maxp``.
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
labels: np.ndarray
|
|
256
|
+
Gij: np.ndarray
|
|
257
|
+
Gj: np.ndarray
|
|
258
|
+
Gjstar: np.ndarray
|
|
259
|
+
W: float
|
|
260
|
+
n_clusters: Optional[int]
|
|
261
|
+
method: str
|
|
262
|
+
W_by_n_clusters: Dict[int, float] = field(default_factory=dict)
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def train_indices(self) -> List[np.ndarray]:
|
|
266
|
+
return [np.flatnonzero(self.labels != f) for f in range(int(self.labels.max()) + 1)]
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def test_indices(self) -> List[np.ndarray]:
|
|
270
|
+
return [np.flatnonzero(self.labels == f) for f in range(int(self.labels.max()) + 1)]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def cv_nearest_distances(coords: np.ndarray, labels: np.ndarray, metric: str) -> np.ndarray:
|
|
274
|
+
"""For each sample, the distance to the nearest sample in a *different* fold."""
|
|
275
|
+
out = np.empty(len(coords))
|
|
276
|
+
for f in np.unique(labels):
|
|
277
|
+
test = labels == f
|
|
278
|
+
out[test] = nearest_distance(coords[test], coords[~test], metric)
|
|
279
|
+
return out
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _merge_clusters(
|
|
283
|
+
clusters: np.ndarray, order_score: np.ndarray, n_splits: int, n: int
|
|
284
|
+
) -> np.ndarray:
|
|
285
|
+
"""CAST's rule: big clusters (>= n/k) get their own fold, the rest are dealt
|
|
286
|
+
round-robin to the remaining folds in the order of their 1st principal component."""
|
|
287
|
+
ids, inverse, counts = np.unique(clusters, return_inverse=True, return_counts=True)
|
|
288
|
+
inverse = np.asarray(inverse).reshape(-1)
|
|
289
|
+
centroid_score = np.bincount(inverse, weights=order_score) / counts
|
|
290
|
+
order = np.argsort(centroid_score, kind="stable")
|
|
291
|
+
fold_of = np.full(len(ids), -1)
|
|
292
|
+
next_fold = 0
|
|
293
|
+
for c in order:
|
|
294
|
+
if counts[c] >= n / n_splits and next_fold < n_splits:
|
|
295
|
+
fold_of[c] = next_fold
|
|
296
|
+
next_fold += 1
|
|
297
|
+
free = np.setdiff1d(np.arange(n_splits), fold_of[fold_of >= 0])
|
|
298
|
+
rest = [c for c in order if fold_of[c] < 0]
|
|
299
|
+
if rest and free.size:
|
|
300
|
+
for i, c in enumerate(rest):
|
|
301
|
+
fold_of[c] = free[i % free.size]
|
|
302
|
+
return fold_of[inverse]
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def knndm(
|
|
306
|
+
train_coords: Any,
|
|
307
|
+
prediction_coords: Any,
|
|
308
|
+
n_splits: int = 10,
|
|
309
|
+
*,
|
|
310
|
+
maxp: float = 0.5,
|
|
311
|
+
clustering: str = "hierarchical",
|
|
312
|
+
metric: str = "euclidean",
|
|
313
|
+
n_candidates: int = 100,
|
|
314
|
+
random_state: Any = None,
|
|
315
|
+
) -> KNNDMResult:
|
|
316
|
+
"""k-fold Nearest Neighbour Distance Matching (Linnenbrink et al. 2024).
|
|
317
|
+
|
|
318
|
+
The training points are clustered into ``q`` clusters for a log-spaced grid of
|
|
319
|
+
``q`` between ``n_splits`` and ``n - 2``. For every ``q``, clusters holding at least
|
|
320
|
+
``n / n_splits`` samples form a fold on their own and the remaining clusters, ordered
|
|
321
|
+
along the first principal component of the coordinates, are merged round-robin into
|
|
322
|
+
the other folds. Configurations where a fold exceeds ``maxp`` of the data are
|
|
323
|
+
discarded; the one minimising the Wasserstein distance ``W`` between the CV distances
|
|
324
|
+
``Gj*`` and the prediction distances ``Gij`` is returned. If ordinary random CV already
|
|
325
|
+
matches (W of LOO distances below 1e-4), random folds are returned.
|
|
326
|
+
|
|
327
|
+
Parameters
|
|
328
|
+
----------
|
|
329
|
+
train_coords, prediction_coords : array-like (n, 2), (m, 2)
|
|
330
|
+
n_splits : int, default=10
|
|
331
|
+
Number of folds ``k``.
|
|
332
|
+
maxp : float, default=0.5
|
|
333
|
+
Maximum fraction of samples in one fold, in [1/k, 1].
|
|
334
|
+
clustering : {"hierarchical", "kmeans"}, default="hierarchical"
|
|
335
|
+
Ward hierarchical clustering (CAST's default ``ward.D2``) or k-means.
|
|
336
|
+
metric : {"euclidean", "haversine"}
|
|
337
|
+
n_candidates : int, default=100
|
|
338
|
+
Length of the grid of cluster numbers (before removing duplicates).
|
|
339
|
+
random_state : int, RandomState instance or None
|
|
340
|
+
Used by k-means and by the random-CV fallback.
|
|
341
|
+
"""
|
|
342
|
+
check_metric(metric)
|
|
343
|
+
k = check_n_splits(n_splits)
|
|
344
|
+
tpts = as_coords(train_coords, metric)
|
|
345
|
+
ppts = as_coords(prediction_coords, metric)
|
|
346
|
+
n = len(tpts)
|
|
347
|
+
if not (1.0 / k <= maxp <= 1.0):
|
|
348
|
+
raise ValueError(f"maxp must be between 1/n_splits and 1, got {maxp}")
|
|
349
|
+
if clustering not in ("hierarchical", "kmeans"):
|
|
350
|
+
raise ValueError(f"clustering must be 'hierarchical' or 'kmeans', got {clustering!r}")
|
|
351
|
+
if n < k + 2:
|
|
352
|
+
raise ValueError(f"kNNDM needs at least n_splits + 2 = {k + 2} training points")
|
|
353
|
+
if len(ppts) == 0:
|
|
354
|
+
raise ValueError("prediction_coords is empty")
|
|
355
|
+
rng = check_random_state(random_state)
|
|
356
|
+
|
|
357
|
+
Gj = nearest_neighbour_loo(tpts, metric)
|
|
358
|
+
Gij = nearest_distance(ppts, tpts, metric)
|
|
359
|
+
|
|
360
|
+
if wasserstein_distance(Gj, Gij) < 1e-4:
|
|
361
|
+
pool = np.tile(np.arange(k), int(np.ceil(n / k)))
|
|
362
|
+
labels = rng.choice(pool, size=n, replace=False)
|
|
363
|
+
Gjstar = cv_nearest_distances(tpts, labels, metric)
|
|
364
|
+
return KNNDMResult(
|
|
365
|
+
labels, Gij, Gj, Gjstar, wasserstein_distance(Gjstar, Gij), None, "random"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
grid = np.unique(np.round(np.exp(np.linspace(np.log(k), np.log(n - 2), n_candidates))))
|
|
369
|
+
grid = grid.astype(int)
|
|
370
|
+
|
|
371
|
+
planar = to_planar(tpts, metric)
|
|
372
|
+
centred = planar - planar.mean(axis=0)
|
|
373
|
+
_, _, vt = np.linalg.svd(centred, full_matrices=False)
|
|
374
|
+
pc1 = centred @ vt[0]
|
|
375
|
+
|
|
376
|
+
if clustering == "hierarchical":
|
|
377
|
+
from scipy.cluster.hierarchy import cut_tree, linkage
|
|
378
|
+
from scipy.spatial.distance import squareform
|
|
379
|
+
|
|
380
|
+
if metric == "haversine":
|
|
381
|
+
dmat = pairwise_distances(tpts, tpts, metric)
|
|
382
|
+
np.fill_diagonal(dmat, 0.0)
|
|
383
|
+
Z = linkage(squareform(dmat, checks=False), method="ward")
|
|
384
|
+
else:
|
|
385
|
+
Z = linkage(tpts, method="ward")
|
|
386
|
+
cuts = np.asarray(cut_tree(Z, n_clusters=grid))
|
|
387
|
+
cluster_sets = [cuts[:, j] for j in range(len(grid))]
|
|
388
|
+
else:
|
|
389
|
+
data = to_unit_sphere(tpts) if metric == "haversine" else tpts
|
|
390
|
+
cluster_sets = []
|
|
391
|
+
for q in grid:
|
|
392
|
+
km = KMeans(
|
|
393
|
+
n_clusters=int(q), n_init=1, random_state=rng.randint(np.iinfo(np.int32).max)
|
|
394
|
+
)
|
|
395
|
+
cluster_sets.append(km.fit_predict(data))
|
|
396
|
+
|
|
397
|
+
W_by_q: Dict[int, float] = {}
|
|
398
|
+
best: Optional[Tuple[float, int, np.ndarray, np.ndarray]] = None
|
|
399
|
+
for q, clusters in zip(grid, cluster_sets):
|
|
400
|
+
labels = _merge_clusters(np.asarray(clusters), pc1, k, n)
|
|
401
|
+
if np.any(labels < 0):
|
|
402
|
+
continue
|
|
403
|
+
if np.max(np.bincount(labels, minlength=k)) / n > maxp:
|
|
404
|
+
continue
|
|
405
|
+
Gjstar_q = cv_nearest_distances(tpts, labels, metric)
|
|
406
|
+
W_q = wasserstein_distance(Gjstar_q, Gij)
|
|
407
|
+
W_by_q[int(q)] = W_q
|
|
408
|
+
if best is None or W_q < best[0]:
|
|
409
|
+
best = (W_q, int(q), labels, Gjstar_q)
|
|
410
|
+
if best is None:
|
|
411
|
+
raise ValueError(
|
|
412
|
+
f"No cluster configuration satisfies maxp={maxp}; increase maxp or n_splits."
|
|
413
|
+
)
|
|
414
|
+
W_best, q_best, labels_best, Gjstar_best = best
|
|
415
|
+
return KNNDMResult(labels_best, Gij, Gj, Gjstar_best, W_best, q_best, clustering, W_by_q)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class KNNDMKFold(SpatialSplitter):
|
|
419
|
+
"""k-fold Nearest Neighbour Distance Matching CV (Linnenbrink et al. 2024).
|
|
420
|
+
|
|
421
|
+
A scikit-learn splitter around :func:`knndm`: whole folds are held out and the
|
|
422
|
+
folds are chosen so that CV test-to-training distances resemble prediction-to-training
|
|
423
|
+
distances.
|
|
424
|
+
|
|
425
|
+
Parameters
|
|
426
|
+
----------
|
|
427
|
+
prediction_points : array-like (m, 2)
|
|
428
|
+
n_splits : int, default=10
|
|
429
|
+
maxp : float, default=0.5
|
|
430
|
+
clustering : {"hierarchical", "kmeans"}, default="hierarchical"
|
|
431
|
+
coords, coord_columns, metric
|
|
432
|
+
See :class:`SpatialBlockKFold`.
|
|
433
|
+
random_state : int, RandomState instance or None
|
|
434
|
+
"""
|
|
435
|
+
|
|
436
|
+
def __init__(
|
|
437
|
+
self,
|
|
438
|
+
prediction_points: Any,
|
|
439
|
+
n_splits: int = 10,
|
|
440
|
+
*,
|
|
441
|
+
maxp: float = 0.5,
|
|
442
|
+
clustering: str = "hierarchical",
|
|
443
|
+
coords: Any = None,
|
|
444
|
+
coord_columns: Optional[Sequence[Any]] = None,
|
|
445
|
+
metric: str = "euclidean",
|
|
446
|
+
random_state: Any = None,
|
|
447
|
+
) -> None:
|
|
448
|
+
self.prediction_points = prediction_points
|
|
449
|
+
self.n_splits = check_n_splits(n_splits)
|
|
450
|
+
self.maxp = maxp
|
|
451
|
+
self.clustering = clustering
|
|
452
|
+
self.coords = coords
|
|
453
|
+
self.coord_columns = coord_columns
|
|
454
|
+
self.metric = metric
|
|
455
|
+
self.random_state = random_state
|
|
456
|
+
|
|
457
|
+
def fit_knndm(self, X: Any = None) -> KNNDMResult:
|
|
458
|
+
"""Run kNNDM and return the full result (distances and W included)."""
|
|
459
|
+
return knndm(
|
|
460
|
+
self._get_coords(X),
|
|
461
|
+
self.prediction_points,
|
|
462
|
+
self.n_splits,
|
|
463
|
+
maxp=self.maxp,
|
|
464
|
+
clustering=self.clustering,
|
|
465
|
+
metric=self.metric,
|
|
466
|
+
random_state=self.random_state,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
def _fold_labels(self, X: Any, y: Any = None, groups: Any = None) -> np.ndarray:
|
|
470
|
+
return self.fit_knndm(X).labels
|
spatialcv/plotting.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Fold maps (optional matplotlib dependency: ``pip install spatialcv[plot]``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional, Sequence
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from ._coords import resolve_coords
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _pyplot() -> Any:
|
|
13
|
+
try:
|
|
14
|
+
import matplotlib.pyplot as plt
|
|
15
|
+
except ImportError as exc: # pragma: no cover - exercised only without matplotlib
|
|
16
|
+
raise ImportError("Plotting requires matplotlib: pip install 'spatialcv[plot]'") from exc
|
|
17
|
+
return plt
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def plot_folds(
|
|
21
|
+
cv: Any,
|
|
22
|
+
X: Any = None,
|
|
23
|
+
y: Any = None,
|
|
24
|
+
groups: Any = None,
|
|
25
|
+
*,
|
|
26
|
+
fold: Optional[int] = None,
|
|
27
|
+
coords: Any = None,
|
|
28
|
+
coord_columns: Optional[Sequence[Any]] = None,
|
|
29
|
+
ax: Any = None,
|
|
30
|
+
cmap: str = "tab10",
|
|
31
|
+
s: float = 12.0,
|
|
32
|
+
) -> Any:
|
|
33
|
+
"""Map the folds of a splitter.
|
|
34
|
+
|
|
35
|
+
With ``fold=None`` every sample is coloured by the split in which it is tested (best
|
|
36
|
+
for partitioning splitters). With ``fold=i`` split ``i`` is shown as training (grey),
|
|
37
|
+
test (colour) and excluded points (hollow, e.g. inside a buffer).
|
|
38
|
+
|
|
39
|
+
Returns the matplotlib ``Axes``.
|
|
40
|
+
"""
|
|
41
|
+
plt = _pyplot()
|
|
42
|
+
metric = getattr(cv, "metric", "euclidean")
|
|
43
|
+
if coords is None and coord_columns is None:
|
|
44
|
+
coords = getattr(cv, "coords", None)
|
|
45
|
+
coord_columns = getattr(cv, "coord_columns", None)
|
|
46
|
+
xy = resolve_coords(X, coords, coord_columns, metric)
|
|
47
|
+
data = X if X is not None else xy
|
|
48
|
+
if ax is None:
|
|
49
|
+
_, ax = plt.subplots(figsize=(6, 6))
|
|
50
|
+
splits = list(cv.split(data, y, groups))
|
|
51
|
+
if fold is None:
|
|
52
|
+
label = np.full(len(xy), -1)
|
|
53
|
+
for i, (_, test) in enumerate(splits):
|
|
54
|
+
label[np.asarray(test)] = i
|
|
55
|
+
colors = plt.get_cmap(cmap)
|
|
56
|
+
for i in range(len(splits)):
|
|
57
|
+
m = label == i
|
|
58
|
+
if m.any():
|
|
59
|
+
ax.scatter(xy[m, 0], xy[m, 1], s=s, color=colors(i % 10), label=f"fold {i}")
|
|
60
|
+
if (label < 0).any():
|
|
61
|
+
m = label < 0
|
|
62
|
+
ax.scatter(xy[m, 0], xy[m, 1], s=s, color="0.8", label="never tested")
|
|
63
|
+
if len(splits) <= 12:
|
|
64
|
+
ax.legend(frameon=False, fontsize="small", markerscale=1.5)
|
|
65
|
+
ax.set_title(f"{type(cv).__name__}: test folds")
|
|
66
|
+
else:
|
|
67
|
+
train, test = (np.asarray(a) for a in splits[fold])
|
|
68
|
+
excluded = np.setdiff1d(np.arange(len(xy)), np.union1d(train, test))
|
|
69
|
+
ax.scatter(xy[train, 0], xy[train, 1], s=s, color="0.6", label="train")
|
|
70
|
+
if excluded.size:
|
|
71
|
+
ax.scatter(
|
|
72
|
+
xy[excluded, 0],
|
|
73
|
+
xy[excluded, 1],
|
|
74
|
+
s=s,
|
|
75
|
+
facecolors="none",
|
|
76
|
+
edgecolors="tab:orange",
|
|
77
|
+
label="excluded",
|
|
78
|
+
)
|
|
79
|
+
ax.scatter(xy[test, 0], xy[test, 1], s=s * 1.5, color="tab:blue", label="test")
|
|
80
|
+
ax.legend(frameon=False, fontsize="small")
|
|
81
|
+
ax.set_title(f"{type(cv).__name__}: split {fold}")
|
|
82
|
+
ax.set_aspect("equal", adjustable="datalim")
|
|
83
|
+
return ax
|
spatialcv/py.typed
ADDED
|
File without changes
|