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/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()])
@@ -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)