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/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