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/cluster.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Cluster-based splitters: k-means in geographic space or in feature space."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional, Sequence
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from sklearn.cluster import KMeans
|
|
9
|
+
from sklearn.utils import check_random_state
|
|
10
|
+
|
|
11
|
+
from ._base import SpatialSplitter, assign_groups_to_folds, check_n_splits
|
|
12
|
+
from ._coords import to_unit_sphere
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _kmeans_folds(
|
|
16
|
+
data: np.ndarray,
|
|
17
|
+
n_splits: int,
|
|
18
|
+
n_clusters: Optional[int],
|
|
19
|
+
random_state: Any,
|
|
20
|
+
balance: bool,
|
|
21
|
+
) -> np.ndarray:
|
|
22
|
+
k = n_splits if n_clusters is None else int(n_clusters)
|
|
23
|
+
if k < n_splits:
|
|
24
|
+
raise ValueError(f"n_clusters ({k}) must be >= n_splits ({n_splits})")
|
|
25
|
+
if len(data) < k:
|
|
26
|
+
raise ValueError(f"{len(data)} samples cannot form {k} clusters")
|
|
27
|
+
rng = check_random_state(random_state)
|
|
28
|
+
km = KMeans(n_clusters=k, n_init=10, random_state=rng.randint(np.iinfo(np.int32).max))
|
|
29
|
+
clusters = km.fit_predict(data)
|
|
30
|
+
_, inverse, counts = np.unique(clusters, return_inverse=True, return_counts=True)
|
|
31
|
+
inverse = np.asarray(inverse).reshape(-1)
|
|
32
|
+
if k == n_splits and len(counts) == n_splits:
|
|
33
|
+
return inverse.astype(int)
|
|
34
|
+
fold_of_cluster = assign_groups_to_folds(counts, n_splits, rng, balance)
|
|
35
|
+
return fold_of_cluster[inverse]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SpatialClusterKFold(SpatialSplitter):
|
|
39
|
+
"""K-fold cross-validation on k-means clusters of the sample coordinates.
|
|
40
|
+
|
|
41
|
+
Samples are grouped by k-means on their coordinates (on 3D unit vectors for lon/lat,
|
|
42
|
+
so clusters respect the sphere) and whole clusters form the test folds
|
|
43
|
+
(Brenning 2012; spatial cluster CV in ``sperrorest`` and ``spatialsample``).
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
n_splits : int, default=5
|
|
48
|
+
n_clusters : int, optional
|
|
49
|
+
Number of k-means clusters. Defaults to ``n_splits`` (one cluster per fold). With
|
|
50
|
+
more clusters than folds, clusters are combined into folds (see ``balance``).
|
|
51
|
+
balance : bool, default=True
|
|
52
|
+
When ``n_clusters > n_splits``, combine clusters so folds have similar sizes.
|
|
53
|
+
coords, coord_columns, metric
|
|
54
|
+
See :class:`SpatialBlockKFold`.
|
|
55
|
+
random_state : int, RandomState instance or None
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
n_splits: int = 5,
|
|
61
|
+
*,
|
|
62
|
+
n_clusters: Optional[int] = None,
|
|
63
|
+
balance: bool = True,
|
|
64
|
+
coords: Any = None,
|
|
65
|
+
coord_columns: Optional[Sequence[Any]] = None,
|
|
66
|
+
metric: str = "euclidean",
|
|
67
|
+
random_state: Any = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
self.n_splits = check_n_splits(n_splits)
|
|
70
|
+
self.n_clusters = n_clusters
|
|
71
|
+
self.balance = balance
|
|
72
|
+
self.coords = coords
|
|
73
|
+
self.coord_columns = coord_columns
|
|
74
|
+
self.metric = metric
|
|
75
|
+
self.random_state = random_state
|
|
76
|
+
|
|
77
|
+
def _fold_labels(self, X: Any, y: Any = None, groups: Any = None) -> np.ndarray:
|
|
78
|
+
xy = self._get_coords(X)
|
|
79
|
+
data = to_unit_sphere(xy) if self.metric == "haversine" else xy
|
|
80
|
+
return _kmeans_folds(data, self.n_splits, self.n_clusters, self.random_state, self.balance)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class EnvironmentalBlockKFold(SpatialSplitter):
|
|
84
|
+
"""K-fold cross-validation on k-means clusters in *feature* space.
|
|
85
|
+
|
|
86
|
+
Samples with similar predictor values end up in the same fold, so each test fold
|
|
87
|
+
represents environmental conditions that are under-represented in its training data.
|
|
88
|
+
This is the "environmental blocking" of Roberts et al. (2017) and blockCV's
|
|
89
|
+
``cv_cluster(..., r = covariates)`` (Valavi et al. 2019).
|
|
90
|
+
|
|
91
|
+
Parameters
|
|
92
|
+
----------
|
|
93
|
+
n_splits : int, default=5
|
|
94
|
+
n_clusters : int, optional
|
|
95
|
+
Defaults to ``n_splits``.
|
|
96
|
+
features : sequence of column names or positions, optional
|
|
97
|
+
Columns of ``X`` to cluster on; all columns by default.
|
|
98
|
+
standardize : bool, default=True
|
|
99
|
+
Scale each feature to zero mean and unit variance before clustering.
|
|
100
|
+
balance : bool, default=True
|
|
101
|
+
random_state : int, RandomState instance or None
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
n_splits: int = 5,
|
|
107
|
+
*,
|
|
108
|
+
n_clusters: Optional[int] = None,
|
|
109
|
+
features: Optional[Sequence[Any]] = None,
|
|
110
|
+
standardize: bool = True,
|
|
111
|
+
balance: bool = True,
|
|
112
|
+
random_state: Any = None,
|
|
113
|
+
) -> None:
|
|
114
|
+
self.n_splits = check_n_splits(n_splits)
|
|
115
|
+
self.n_clusters = n_clusters
|
|
116
|
+
self.features = features
|
|
117
|
+
self.standardize = standardize
|
|
118
|
+
self.balance = balance
|
|
119
|
+
self.random_state = random_state
|
|
120
|
+
|
|
121
|
+
def _fold_labels(self, X: Any, y: Any = None, groups: Any = None) -> np.ndarray:
|
|
122
|
+
if X is None:
|
|
123
|
+
raise ValueError("EnvironmentalBlockKFold needs the feature matrix X")
|
|
124
|
+
if self.features is not None:
|
|
125
|
+
cols = list(self.features)
|
|
126
|
+
if hasattr(X, "iloc"):
|
|
127
|
+
sub = X[cols] if all(c in X.columns for c in cols) else X.iloc[:, cols]
|
|
128
|
+
data = np.asarray(sub, dtype=float)
|
|
129
|
+
else:
|
|
130
|
+
data = np.asarray(X)[:, cols].astype(float)
|
|
131
|
+
else:
|
|
132
|
+
if hasattr(X, "select_dtypes"):
|
|
133
|
+
X = X.select_dtypes(include=[np.number])
|
|
134
|
+
data = np.asarray(X, dtype=float)
|
|
135
|
+
if data.ndim == 1:
|
|
136
|
+
data = data[:, None]
|
|
137
|
+
if not np.all(np.isfinite(data)):
|
|
138
|
+
raise ValueError("feature matrix contains NaN or infinite values")
|
|
139
|
+
if self.standardize:
|
|
140
|
+
std = data.std(axis=0)
|
|
141
|
+
std[std == 0] = 1.0
|
|
142
|
+
data = (data - data.mean(axis=0)) / std
|
|
143
|
+
return _kmeans_folds(data, self.n_splits, self.n_clusters, self.random_state, self.balance)
|
spatialcv/datasets.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Synthetic spatially autocorrelated data for examples and tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Any, Tuple
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from sklearn.utils import check_random_state
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def gaussian_random_field(
|
|
13
|
+
coords: Any,
|
|
14
|
+
correlation_range: float,
|
|
15
|
+
*,
|
|
16
|
+
n_components: int = 500,
|
|
17
|
+
random_state: Any = None,
|
|
18
|
+
) -> np.ndarray:
|
|
19
|
+
"""Unit-variance stationary Gaussian random field sampled at ``coords``.
|
|
20
|
+
|
|
21
|
+
Uses random Fourier features (Rahimi & Recht 2007) to approximate a Gaussian
|
|
22
|
+
covariance whose practical range (correlation 0.05) is ``correlation_range``.
|
|
23
|
+
"""
|
|
24
|
+
rng = check_random_state(random_state)
|
|
25
|
+
xy = np.asarray(coords, dtype=float)
|
|
26
|
+
length = correlation_range / math.sqrt(2.0 * math.log(20.0))
|
|
27
|
+
omega = rng.normal(scale=1.0 / length, size=(2, n_components))
|
|
28
|
+
phase = rng.uniform(0.0, 2.0 * math.pi, size=n_components)
|
|
29
|
+
return np.asarray(math.sqrt(2.0 / n_components) * np.cos(xy @ omega + phase).sum(axis=1))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def make_spatial_regression(
|
|
33
|
+
n_samples: int = 500,
|
|
34
|
+
*,
|
|
35
|
+
n_features: int = 4,
|
|
36
|
+
extent: float = 10_000.0,
|
|
37
|
+
correlation_range: float = 1_500.0,
|
|
38
|
+
noise: float = 0.2,
|
|
39
|
+
sampling: str = "clustered",
|
|
40
|
+
n_clusters: int = 8,
|
|
41
|
+
prediction_points: Any = None,
|
|
42
|
+
random_state: Any = None,
|
|
43
|
+
) -> Tuple[np.ndarray, ...]:
|
|
44
|
+
"""Regression data whose features *and* residuals are spatially autocorrelated.
|
|
45
|
+
|
|
46
|
+
Samples lie in the square ``[0, extent]^2``. Each feature is an independent Gaussian
|
|
47
|
+
random field; the target is a non-linear function of the features plus another
|
|
48
|
+
random field the features cannot explain, plus white noise.
|
|
49
|
+
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
sampling : {"clustered", "uniform"}, default="clustered"
|
|
53
|
+
``"clustered"`` draws samples around ``n_clusters`` random centres (a Thomas
|
|
54
|
+
process), the typical situation of field data, where random CV is most optimistic.
|
|
55
|
+
prediction_points : array-like (m, 2), optional
|
|
56
|
+
Extra locations drawn from the *same* fields. Their features and target are
|
|
57
|
+
returned too, so the true map accuracy can be compared with CV estimates.
|
|
58
|
+
|
|
59
|
+
Returns
|
|
60
|
+
-------
|
|
61
|
+
coords : ndarray (n_samples, 2)
|
|
62
|
+
X : ndarray (n_samples, n_features)
|
|
63
|
+
y : ndarray (n_samples,)
|
|
64
|
+
X_pred, y_pred : ndarray
|
|
65
|
+
Only when ``prediction_points`` is given.
|
|
66
|
+
"""
|
|
67
|
+
rng = check_random_state(random_state)
|
|
68
|
+
if sampling == "uniform":
|
|
69
|
+
coords = rng.uniform(0.0, extent, size=(n_samples, 2))
|
|
70
|
+
elif sampling == "clustered":
|
|
71
|
+
centres = rng.uniform(0.1 * extent, 0.9 * extent, size=(n_clusters, 2))
|
|
72
|
+
which = rng.randint(n_clusters, size=n_samples)
|
|
73
|
+
coords = centres[which] + rng.normal(scale=0.05 * extent, size=(n_samples, 2))
|
|
74
|
+
coords = np.clip(coords, 0.0, extent)
|
|
75
|
+
else:
|
|
76
|
+
raise ValueError(f"sampling must be 'clustered' or 'uniform', got {sampling!r}")
|
|
77
|
+
pred = np.empty((0, 2)) if prediction_points is None else np.asarray(prediction_points, float)
|
|
78
|
+
allxy = np.vstack([coords, pred])
|
|
79
|
+
X = np.column_stack(
|
|
80
|
+
[
|
|
81
|
+
gaussian_random_field(allxy, correlation_range, random_state=rng)
|
|
82
|
+
for _ in range(n_features)
|
|
83
|
+
]
|
|
84
|
+
)
|
|
85
|
+
signal = np.sin(X[:, 0]) + 0.5 * X[:, 1 % n_features] ** 2
|
|
86
|
+
if n_features > 2:
|
|
87
|
+
signal = signal + 0.5 * X[:, 2]
|
|
88
|
+
residual = gaussian_random_field(allxy, correlation_range, random_state=rng)
|
|
89
|
+
y = signal + residual + noise * rng.normal(size=len(allxy))
|
|
90
|
+
if prediction_points is None:
|
|
91
|
+
return coords, X, y
|
|
92
|
+
n = n_samples
|
|
93
|
+
return coords, X[:n], y[:n], X[n:], y[n:]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def make_prediction_grid(extent: float = 10_000.0, n_per_side: int = 40) -> np.ndarray:
|
|
97
|
+
"""Regular grid of cell centres covering ``[0, extent]^2`` (prediction locations)."""
|
|
98
|
+
step = extent / n_per_side
|
|
99
|
+
g = (np.arange(n_per_side) + 0.5) * step
|
|
100
|
+
gx, gy = np.meshgrid(g, g)
|
|
101
|
+
return np.column_stack([gx.ravel(), gy.ravel()])
|
spatialcv/diagnostics.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Diagnostics: how far are CV test points from their training data?
|
|
2
|
+
|
|
3
|
+
Compares the nearest-neighbour distance distributions of Milà et al. (2022):
|
|
4
|
+
|
|
5
|
+
* ``Gij``: prediction location -> nearest training sample (what the model faces in use);
|
|
6
|
+
* ``Gjstar``: CV test sample -> nearest sample in its training set (what CV measures);
|
|
7
|
+
* ``Gj``: training sample -> nearest other training sample (ordinary random LOO CV).
|
|
8
|
+
|
|
9
|
+
If ``Gjstar`` sits well to the left of ``Gij`` the CV estimate is optimistic. The
|
|
10
|
+
Wasserstein distance ``W`` (Linnenbrink et al. 2024) summarises the mismatch.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from ._coords import (
|
|
21
|
+
as_coords,
|
|
22
|
+
check_metric,
|
|
23
|
+
nearest_distance,
|
|
24
|
+
nearest_neighbour_loo,
|
|
25
|
+
resolve_coords,
|
|
26
|
+
)
|
|
27
|
+
from .nndm import wasserstein_distance
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class FoldDiagnostics:
|
|
32
|
+
"""Result of :func:`fold_diagnostics`.
|
|
33
|
+
|
|
34
|
+
Attributes
|
|
35
|
+
----------
|
|
36
|
+
Gjstar : ndarray
|
|
37
|
+
Test -> training nearest-neighbour distance of every test sample in every split.
|
|
38
|
+
Gj : ndarray
|
|
39
|
+
LOO nearest-neighbour distance among all samples.
|
|
40
|
+
Gij : ndarray or None
|
|
41
|
+
Prediction -> training distance (None without ``prediction_points``).
|
|
42
|
+
W : float or None
|
|
43
|
+
Wasserstein distance between ``Gjstar`` and ``Gij``.
|
|
44
|
+
W_random : float or None
|
|
45
|
+
Wasserstein distance between ``Gj`` and ``Gij`` (what random LOO would give).
|
|
46
|
+
n_train, n_test : list of int
|
|
47
|
+
Sizes of every split.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
Gjstar: np.ndarray
|
|
51
|
+
Gj: np.ndarray
|
|
52
|
+
Gij: Optional[np.ndarray]
|
|
53
|
+
W: Optional[float]
|
|
54
|
+
W_random: Optional[float]
|
|
55
|
+
n_train: List[int]
|
|
56
|
+
n_test: List[int]
|
|
57
|
+
|
|
58
|
+
def summary(self) -> Dict[str, Any]:
|
|
59
|
+
"""Medians and W statistics as a plain dict."""
|
|
60
|
+
out: Dict[str, Any] = {
|
|
61
|
+
"n_splits": len(self.n_test),
|
|
62
|
+
"median_train_size": float(np.median(self.n_train)),
|
|
63
|
+
"median_test_size": float(np.median(self.n_test)),
|
|
64
|
+
"median_Gjstar": float(np.median(self.Gjstar)),
|
|
65
|
+
"median_Gj": float(np.median(self.Gj)),
|
|
66
|
+
}
|
|
67
|
+
if self.Gij is not None:
|
|
68
|
+
out["median_Gij"] = float(np.median(self.Gij))
|
|
69
|
+
out["W"] = self.W
|
|
70
|
+
out["W_random"] = self.W_random
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
def __str__(self) -> str:
|
|
74
|
+
s = self.summary()
|
|
75
|
+
lines = [f"FoldDiagnostics ({s['n_splits']} splits)"]
|
|
76
|
+
for k, v in s.items():
|
|
77
|
+
if k == "n_splits":
|
|
78
|
+
continue
|
|
79
|
+
lines.append(f" {k:<18} {v:.4g}" if isinstance(v, float) else f" {k:<18} {v}")
|
|
80
|
+
return "\n".join(lines)
|
|
81
|
+
|
|
82
|
+
def plot(self, ax: Any = None) -> Any:
|
|
83
|
+
"""ECDFs of the three distance distributions (requires matplotlib)."""
|
|
84
|
+
from .plotting import _pyplot
|
|
85
|
+
|
|
86
|
+
plt = _pyplot()
|
|
87
|
+
if ax is None:
|
|
88
|
+
_, ax = plt.subplots(figsize=(6, 4))
|
|
89
|
+
series = [
|
|
90
|
+
(self.Gj, "random LOO (Gj)", "0.55"),
|
|
91
|
+
(self.Gjstar, "this CV (Gj*)", "tab:blue"),
|
|
92
|
+
]
|
|
93
|
+
if self.Gij is not None:
|
|
94
|
+
series.append((self.Gij, "prediction (Gij)", "tab:red"))
|
|
95
|
+
for values, label, color in series:
|
|
96
|
+
x = np.sort(values)
|
|
97
|
+
ax.step(x, np.arange(1, x.size + 1) / x.size, where="post", label=label, color=color)
|
|
98
|
+
ax.set_xlabel("nearest-neighbour distance")
|
|
99
|
+
ax.set_ylabel("ECDF")
|
|
100
|
+
ax.legend(frameon=False)
|
|
101
|
+
return ax
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def fold_diagnostics(
|
|
105
|
+
cv: Any,
|
|
106
|
+
X: Any = None,
|
|
107
|
+
y: Any = None,
|
|
108
|
+
groups: Any = None,
|
|
109
|
+
*,
|
|
110
|
+
prediction_points: Any = None,
|
|
111
|
+
coords: Any = None,
|
|
112
|
+
coord_columns: Optional[Sequence[Any]] = None,
|
|
113
|
+
metric: Optional[str] = None,
|
|
114
|
+
) -> FoldDiagnostics:
|
|
115
|
+
"""Nearest-neighbour distance diagnostics for any splitter.
|
|
116
|
+
|
|
117
|
+
Parameters
|
|
118
|
+
----------
|
|
119
|
+
cv : cross-validator
|
|
120
|
+
Any object with ``split`` (spatialcv or scikit-learn splitters).
|
|
121
|
+
X, y, groups
|
|
122
|
+
Passed to ``cv.split``.
|
|
123
|
+
prediction_points : array-like (m, 2), optional
|
|
124
|
+
Where the model will predict. Enables ``Gij`` and the W statistics.
|
|
125
|
+
coords, coord_columns, metric
|
|
126
|
+
Sample coordinates; default to the splitter's own settings, then to ``X``.
|
|
127
|
+
"""
|
|
128
|
+
metric = str(metric or getattr(cv, "metric", "euclidean"))
|
|
129
|
+
check_metric(metric)
|
|
130
|
+
if coords is None and coord_columns is None:
|
|
131
|
+
coords = getattr(cv, "coords", None)
|
|
132
|
+
coord_columns = getattr(cv, "coord_columns", None)
|
|
133
|
+
xy = resolve_coords(X, coords, coord_columns, metric)
|
|
134
|
+
data = X if X is not None else xy
|
|
135
|
+
gjstar: List[np.ndarray] = []
|
|
136
|
+
n_train: List[int] = []
|
|
137
|
+
n_test: List[int] = []
|
|
138
|
+
for train, test in cv.split(data, y, groups):
|
|
139
|
+
train = np.asarray(train)
|
|
140
|
+
test = np.asarray(test)
|
|
141
|
+
gjstar.append(nearest_distance(xy[test], xy[train], metric))
|
|
142
|
+
n_train.append(len(train))
|
|
143
|
+
n_test.append(len(test))
|
|
144
|
+
Gjstar = np.concatenate(gjstar) if gjstar else np.empty(0)
|
|
145
|
+
Gj = nearest_neighbour_loo(xy, metric)
|
|
146
|
+
Gij = W = W_random = None
|
|
147
|
+
if prediction_points is not None:
|
|
148
|
+
Gij = nearest_distance(as_coords(prediction_points, metric), xy, metric)
|
|
149
|
+
W = wasserstein_distance(Gjstar, Gij)
|
|
150
|
+
W_random = wasserstein_distance(Gj, Gij)
|
|
151
|
+
return FoldDiagnostics(Gjstar, Gj, Gij, W, W_random, n_train, n_test)
|