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/variogram.py ADDED
@@ -0,0 +1,273 @@
1
+ """Empirical semivariogram and model fitting to estimate the autocorrelation range.
2
+
3
+ Pure numpy. The empirical semivariogram (Matheron's estimator)
4
+
5
+ gamma(h) = 1 / (2 N(h)) * sum_{|s_i - s_j| in bin h} (z_i - z_j)^2
6
+
7
+ is fitted with a spherical or exponential model plus nugget by weighted least squares
8
+ with weights ``N(h) / h^2`` (Cressie 1985; gstat's default ``fit.method = 7``). For a
9
+ fixed range the model is linear in nugget and partial sill, so those are solved exactly
10
+ (non-negative) while the range is found by a grid search followed by refinement.
11
+
12
+ The *effective range* (distance where the model reaches ~95 % of the sill; equal to the
13
+ range parameter for the spherical model and three times it for the exponential model) is
14
+ a sensible minimum block size or buffer distance (Roberts et al. 2017; Valavi et al. 2019).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ from typing import Any, Optional, Tuple
21
+
22
+ import numpy as np
23
+ from sklearn.utils import check_random_state
24
+
25
+ from ._coords import as_coords, check_metric, pairwise_distances
26
+
27
+ _MODELS = ("spherical", "exponential")
28
+
29
+
30
+ def _basis(h: np.ndarray, a: float, model: str) -> np.ndarray:
31
+ h = np.asarray(h, dtype=float)
32
+ if model == "spherical":
33
+ t = np.minimum(h / a, 1.0)
34
+ return np.asarray(1.5 * t - 0.5 * t**3)
35
+ return np.asarray(1.0 - np.exp(-h / a))
36
+
37
+
38
+ @dataclass
39
+ class Variogram:
40
+ """A fitted semivariogram model.
41
+
42
+ Attributes
43
+ ----------
44
+ model : str
45
+ ``"spherical"`` or ``"exponential"``.
46
+ nugget, partial_sill : float
47
+ range_parameter : float
48
+ The model's ``a`` parameter.
49
+ effective_range : float
50
+ Distance at which spatial autocorrelation becomes negligible: ``a`` (spherical) or
51
+ ``3 a`` (exponential, 95 % of the sill).
52
+ lags, gamma : ndarray
53
+ Mean pair distance and semivariance of each non-empty lag bin.
54
+ counts : ndarray
55
+ Number of point pairs in each bin.
56
+ """
57
+
58
+ model: str
59
+ nugget: float
60
+ partial_sill: float
61
+ range_parameter: float
62
+ effective_range: float
63
+ lags: np.ndarray
64
+ gamma: np.ndarray
65
+ counts: np.ndarray
66
+ sse: float
67
+
68
+ @property
69
+ def sill(self) -> float:
70
+ return self.nugget + self.partial_sill
71
+
72
+ @property
73
+ def range(self) -> float:
74
+ """Alias for :attr:`effective_range`."""
75
+ return self.effective_range
76
+
77
+ def __call__(self, h: Any) -> np.ndarray:
78
+ """Model semivariance at distance(s) ``h``."""
79
+ h = np.asarray(h, dtype=float)
80
+ out = self.nugget + self.partial_sill * _basis(h, self.range_parameter, self.model)
81
+ return np.where(h == 0, 0.0, out)
82
+
83
+ def plot(self, ax: Any = None) -> Any:
84
+ """Plot the empirical points and the fitted model (requires matplotlib)."""
85
+ from .plotting import _pyplot
86
+
87
+ plt = _pyplot()
88
+ if ax is None:
89
+ _, ax = plt.subplots(figsize=(6, 4))
90
+ ax.scatter(self.lags, self.gamma, color="0.2", s=18, label="empirical", zorder=3)
91
+ h = np.linspace(0, float(self.lags.max()) * 1.05, 200)
92
+ ax.plot(h, self(h), color="tab:blue", label=f"{self.model} fit")
93
+ ax.axvline(self.effective_range, color="tab:red", ls="--", lw=1, label="effective range")
94
+ ax.set_xlabel("distance")
95
+ ax.set_ylabel("semivariance")
96
+ ax.legend(frameon=False)
97
+ return ax
98
+
99
+
100
+ def empirical_variogram(
101
+ coords: Any,
102
+ values: Any,
103
+ *,
104
+ n_lags: int = 15,
105
+ max_lag: Optional[float] = None,
106
+ metric: str = "euclidean",
107
+ max_points: Optional[int] = 2000,
108
+ random_state: Any = None,
109
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
110
+ """Matheron semivariogram with ``n_lags`` bins ``((i-1) w, i w]``, ``w = max_lag / n_lags``.
111
+
112
+ Returns ``(lags, gamma, counts)`` for the non-empty bins, where ``lags`` is the mean
113
+ pair distance in each bin. ``max_lag`` defaults to one third of the bounding-box
114
+ diagonal (gstat's default cutoff). With more than ``max_points`` samples a random
115
+ subset is used.
116
+ """
117
+ check_metric(metric)
118
+ xy = as_coords(coords, metric)
119
+ z = np.asarray(values, dtype=float).ravel()
120
+ if len(z) != len(xy):
121
+ raise ValueError(f"values has {len(z)} entries but coords has {len(xy)}")
122
+ finite = np.isfinite(z)
123
+ xy, z = xy[finite], z[finite]
124
+ if len(z) < 3:
125
+ raise ValueError("need at least three finite values")
126
+ if n_lags < 3:
127
+ raise ValueError("n_lags must be >= 3")
128
+ if max_points is not None and len(z) > max_points:
129
+ idx = check_random_state(random_state).choice(len(z), size=max_points, replace=False)
130
+ xy, z = xy[idx], z[idx]
131
+ if max_lag is None:
132
+ lo, hi = xy.min(axis=0), xy.max(axis=0)
133
+ diag = float(pairwise_distances(lo[None, :], hi[None, :], metric)[0, 0])
134
+ max_lag = diag / 3.0
135
+ if not max_lag > 0:
136
+ raise ValueError("max_lag must be positive (are all points at one location?)")
137
+ width = max_lag / n_lags
138
+ sum_d = np.zeros(n_lags)
139
+ sum_sq = np.zeros(n_lags)
140
+ cnt = np.zeros(n_lags)
141
+ n = len(z)
142
+ chunk = 256
143
+ for start in range(0, n - 1, chunk):
144
+ stop = min(start + chunk, n - 1)
145
+ d = pairwise_distances(xy[start:stop], xy, metric)
146
+ sq = (z[start:stop, None] - z[None, :]) ** 2
147
+ rows = np.arange(start, stop)[:, None]
148
+ upper = np.arange(n)[None, :] > rows
149
+ sel = upper & (d > 0) & (d <= max_lag)
150
+ dd, ss = d[sel], sq[sel]
151
+ b = np.clip(np.ceil(dd / width).astype(int) - 1, 0, n_lags - 1)
152
+ sum_d += np.bincount(b, weights=dd, minlength=n_lags)
153
+ sum_sq += np.bincount(b, weights=ss, minlength=n_lags)
154
+ cnt += np.bincount(b, minlength=n_lags)
155
+ keep = cnt > 0
156
+ if keep.sum() < 3:
157
+ raise ValueError("fewer than three non-empty lag bins; increase max_lag")
158
+ lags = sum_d[keep] / cnt[keep]
159
+ gamma = sum_sq[keep] / (2.0 * cnt[keep])
160
+ return lags, gamma, cnt[keep].astype(int)
161
+
162
+
163
+ def _fit_fixed_range(
164
+ lags: np.ndarray, gamma: np.ndarray, w: np.ndarray, f: np.ndarray
165
+ ) -> Tuple[float, float, float]:
166
+ """Non-negative WLS of gamma ~ c0 + c1 f. Returns (c0, c1, sse)."""
167
+ sw = w.sum()
168
+ swf = (w * f).sum()
169
+ swff = (w * f * f).sum()
170
+ swg = (w * gamma).sum()
171
+ swfg = (w * f * gamma).sum()
172
+ det = sw * swff - swf * swf
173
+ c0 = c1 = -1.0
174
+ if det > 1e-12 * max(sw * swff, 1e-300):
175
+ c0 = (swff * swg - swf * swfg) / det
176
+ c1 = (sw * swfg - swf * swg) / det
177
+ if c0 < 0 or c1 < 0:
178
+ cand = []
179
+ c1_only = max(swfg / swff, 0.0) if swff > 0 else 0.0
180
+ cand.append((0.0, c1_only))
181
+ cand.append((max(swg / sw, 0.0), 0.0))
182
+ best = min(cand, key=lambda p: float((w * (gamma - p[0] - p[1] * f) ** 2).sum()))
183
+ c0, c1 = best
184
+ sse = float((w * (gamma - c0 - c1 * f) ** 2).sum())
185
+ return float(c0), float(c1), sse
186
+
187
+
188
+ def fit_variogram_model(lags: Any, gamma: Any, counts: Any, model: str = "spherical") -> Variogram:
189
+ """Fit a spherical or exponential model (with nugget) to an empirical variogram."""
190
+ if model not in _MODELS:
191
+ raise ValueError(f"model must be one of {_MODELS}, got {model!r}")
192
+ lags = np.asarray(lags, dtype=float)
193
+ gamma = np.asarray(gamma, dtype=float)
194
+ counts = np.asarray(counts, dtype=float)
195
+ w = counts / np.maximum(lags, 1e-12) ** 2
196
+ w = w / w.sum()
197
+ hmax = float(lags.max())
198
+ hmin = float(lags.min())
199
+ # candidate *effective* ranges; the exponential parameter is a third of it
200
+ eff = np.geomspace(max(hmin * 0.25, hmax * 1e-3), hmax * 3.0, 300)
201
+ factor = 1.0 if model == "spherical" else 3.0
202
+
203
+ def evaluate(e: float) -> Tuple[float, float, float]:
204
+ return _fit_fixed_range(lags, gamma, w, _basis(lags, e / factor, model))
205
+
206
+ sses = np.array([evaluate(e)[2] for e in eff])
207
+ i = int(np.argmin(sses))
208
+ lo = eff[max(i - 1, 0)]
209
+ hi = eff[min(i + 1, len(eff) - 1)]
210
+ fine = np.linspace(lo, hi, 201)
211
+ fine_sse = np.array([evaluate(e)[2] for e in fine])
212
+ e_best = float(fine[int(np.argmin(fine_sse))])
213
+ c0, c1, sse = evaluate(e_best)
214
+ return Variogram(
215
+ model=model,
216
+ nugget=c0,
217
+ partial_sill=c1,
218
+ range_parameter=e_best / factor,
219
+ effective_range=e_best,
220
+ lags=lags,
221
+ gamma=gamma,
222
+ counts=counts.astype(int),
223
+ sse=sse,
224
+ )
225
+
226
+
227
+ def estimate_autocorrelation_range(
228
+ coords: Any,
229
+ values: Any,
230
+ *,
231
+ model: str = "spherical",
232
+ n_lags: int = 15,
233
+ max_lag: Optional[float] = None,
234
+ metric: str = "euclidean",
235
+ max_points: Optional[int] = 2000,
236
+ random_state: Any = None,
237
+ ) -> Variogram:
238
+ """Estimate the range of spatial autocorrelation of ``values`` at ``coords``.
239
+
240
+ Computes the empirical semivariogram and fits a variogram model. Pass the response,
241
+ a key predictor, or (best) the residuals of a non-spatial model.
242
+
243
+ Parameters
244
+ ----------
245
+ coords : array-like (n, 2)
246
+ Planar coordinates, or ``(lon, lat)`` degrees with ``metric="haversine"`` (the
247
+ range is then in metres).
248
+ values : array-like (n,)
249
+ model : {"spherical", "exponential", "auto"}, default="spherical"
250
+ ``"auto"`` fits both and keeps the smaller weighted squared error.
251
+ n_lags, max_lag, max_points, random_state
252
+ See :func:`empirical_variogram`.
253
+
254
+ Returns
255
+ -------
256
+ Variogram
257
+ ``.effective_range`` (alias ``.range``) is the estimated autocorrelation range.
258
+ """
259
+ lags, gamma, counts = empirical_variogram(
260
+ coords,
261
+ values,
262
+ n_lags=n_lags,
263
+ max_lag=max_lag,
264
+ metric=metric,
265
+ max_points=max_points,
266
+ random_state=random_state,
267
+ )
268
+ if not np.any(gamma > 0):
269
+ raise ValueError("values show no variation; the range is undefined")
270
+ if model == "auto":
271
+ fits = [fit_variogram_model(lags, gamma, counts, m) for m in _MODELS]
272
+ return min(fits, key=lambda v: v.sse)
273
+ return fit_variogram_model(lags, gamma, counts, model)
@@ -0,0 +1,311 @@
1
+ Metadata-Version: 2.4
2
+ Name: spatialcv
3
+ Version: 0.1.0
4
+ Summary: Honest cross-validation for spatial machine learning: scikit-learn compatible spatial block, cluster, buffered, NNDM and kNNDM splitters.
5
+ Author-email: Yusuf Eminoğlu <yusufeminoglu@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/YusufEminoglu/spatialcv_sdk
8
+ Project-URL: Repository, https://github.com/YusufEminoglu/spatialcv_sdk
9
+ Project-URL: Issues, https://github.com/YusufEminoglu/spatialcv_sdk/issues
10
+ Project-URL: Documentation, https://github.com/YusufEminoglu/spatialcv_sdk#readme
11
+ Project-URL: Changelog, https://github.com/YusufEminoglu/spatialcv_sdk/blob/main/CHANGELOG.md
12
+ Keywords: spatial-cross-validation,cross-validation,spatial-autocorrelation,geoai,machine-learning,scikit-learn,block-cross-validation,nndm,knndm,variogram,gis,remote-sensing,geostatistics,model-evaluation
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Operating System :: OS Independent
21
+ Classifier: Topic :: Scientific/Engineering :: GIS
22
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
23
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
24
+ Classifier: Intended Audience :: Science/Research
25
+ Classifier: Intended Audience :: Developers
26
+ Classifier: Typing :: Typed
27
+ Requires-Python: >=3.9
28
+ Description-Content-Type: text/markdown
29
+ License-File: LICENSE
30
+ Requires-Dist: numpy>=1.21.0
31
+ Requires-Dist: scikit-learn>=1.0.0
32
+ Provides-Extra: plot
33
+ Requires-Dist: matplotlib>=3.5.0; extra == "plot"
34
+ Provides-Extra: geo
35
+ Requires-Dist: geopandas>=0.12.0; extra == "geo"
36
+ Requires-Dist: matplotlib>=3.5.0; extra == "geo"
37
+ Provides-Extra: dev
38
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
39
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
40
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
41
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
42
+ Requires-Dist: pandas>=1.3.0; extra == "dev"
43
+ Dynamic: license-file
44
+
45
+ # spatialcv
46
+
47
+ **Honest cross-validation for machine learning on spatial data.**
48
+
49
+ `spatialcv` provides scikit-learn compatible splitters that keep spatially autocorrelated
50
+ neighbours out of each test fold's training data, plus the tools to choose a block size or
51
+ buffer and to check whether your cross-validation is as hard as the real prediction task.
52
+
53
+ ```bash
54
+ pip install spatialcv # numpy + scikit-learn
55
+ pip install "spatialcv[plot]" # + matplotlib for fold maps
56
+ pip install "spatialcv[geo]" # + geopandas (GeoDataFrame input) and matplotlib
57
+ ```
58
+
59
+ Python 3.9+. Every splitter has `split(X, y, groups)` and `get_n_splits()`, so it plugs into
60
+ `cross_val_score`, `cross_validate`, `cross_val_predict`, `GridSearchCV`,
61
+ `RandomizedSearchCV` and pipelines unchanged.
62
+
63
+ ## Why spatial cross-validation?
64
+
65
+ Nearby observations are similar (Tobler's first law). Random K-fold puts a sample's
66
+ neighbours in the training set, so a flexible model (random forest, gradient boosting,
67
+ a neural network, anything that sees coordinates or spatially smooth covariates) can
68
+ "look up" the answer instead of learning a transferable relationship. The CV score then
69
+ reflects interpolation between close neighbours, while the map is used far from any
70
+ sample. Large-scale mapping studies have reported random-CV accuracies that collapse
71
+ under spatial validation (Ploton et al. 2020).
72
+
73
+ Spatial CV makes the test data *as far from the training data as the prediction
74
+ locations are*. The splitters here do this in different ways:
75
+
76
+ | Splitter | Idea | Test sets | Key parameters | Reference |
77
+ |---|---|---|---|---|
78
+ | `SpatialBlockKFold` | square or hexagonal blocks are held out whole; random, balanced or systematic block-to-fold assignment | partition | `block_size`, `shape`, `assignment`, `balance` | Roberts et al. 2017; Valavi et al. 2019 |
79
+ | `SpatialClusterKFold` | k-means on coordinates (on the sphere for lon/lat); clusters are held out | partition | `n_clusters`, `balance` | Brenning 2012 |
80
+ | `EnvironmentalBlockKFold` | k-means in standardised *feature* space; tests extrapolation to new conditions | partition | `features`, `standardize` | Roberts et al. 2017; Valavi et al. 2019 |
81
+ | `SpatialKFoldWithBuffer` | any K-fold (random by default) plus a dead zone: training points within `buffer` of a test point are dropped | partition | `buffer`, `cv` | Pohjankukka et al. 2017 |
82
+ | `BufferedLeaveOneOut` | leave-one-out with a circular dead zone around each test point | single samples | `buffer` | Le Rest et al. 2014 |
83
+ | `NNDMLeaveOneOut` | LOO whose training sets are thinned until the CV nearest-neighbour distance distribution matches prediction-to-sample distances | single samples | `prediction_points`, `phi`, `min_train` | Milà et al. 2022 |
84
+ | `KNNDMKFold` | k-fold version of NNDM: clusters are merged into folds to minimise the Wasserstein distance W between those distributions | partition | `prediction_points`, `n_splits`, `maxp`, `clustering` | Linnenbrink et al. 2024 |
85
+
86
+ Helpers:
87
+
88
+ | Function | Purpose |
89
+ |---|---|
90
+ | `estimate_autocorrelation_range` | empirical semivariogram + fitted spherical / exponential model (pure numpy); gives a block size or buffer |
91
+ | `fold_diagnostics` | nearest-neighbour distance distributions: prediction→train (`Gij`), CV test→train (`Gjstar`), LOO (`Gj`) and the W statistic, for *any* splitter |
92
+ | `plot_folds` | map of folds, or of train / test / excluded points of one split (matplotlib) |
93
+ | `nndm`, `knndm` | the NNDM / kNNDM algorithms as functions returning folds and distances |
94
+ | `make_spatial_regression`, `make_prediction_grid` | synthetic autocorrelated data for experiments |
95
+
96
+ ## Quick start
97
+
98
+ ```python
99
+ import numpy as np
100
+ from sklearn.ensemble import RandomForestRegressor
101
+ from sklearn.model_selection import GridSearchCV, KFold, cross_val_score
102
+
103
+ import spatialcv as scv
104
+
105
+ coords, X, y = scv.make_spatial_regression(400, random_state=42)
106
+ features = np.column_stack([X, coords])
107
+ model = RandomForestRegressor(n_estimators=100, random_state=0, n_jobs=-1)
108
+
109
+ # 1. How far does spatial autocorrelation reach?
110
+ vg = scv.estimate_autocorrelation_range(coords, y, model="auto")
111
+ print(f"range = {vg.effective_range:.0f} m, model = {vg.model}, nugget = {vg.nugget:.2f}")
112
+
113
+ # 2. Random vs spatial K-fold
114
+ random_cv = KFold(5, shuffle=True, random_state=0)
115
+ block_cv = scv.SpatialBlockKFold(5, block_size=vg.effective_range, coords=coords, random_state=0)
116
+ for name, cv in [("random KFold", random_cv), ("SpatialBlockKFold", block_cv)]:
117
+ r2 = cross_val_score(model, features, y, cv=cv, scoring="r2")
118
+ print(f"{name:<18} R2 = {r2.mean():.3f} +/- {r2.std():.3f}")
119
+
120
+ # 3. Hyper-parameter search with spatial folds
121
+ search = GridSearchCV(model, {"max_features": [0.3, 1.0]}, cv=block_cv, scoring="r2")
122
+ search.fit(features, y)
123
+ print("best:", search.best_params_, f"R2 = {search.best_score_:.3f}")
124
+
125
+ # 4. Are the CV test points as far from training data as the prediction grid?
126
+ grid = scv.make_prediction_grid(extent=10_000, n_per_side=40)
127
+ for name, cv in [("random KFold", random_cv), ("SpatialBlockKFold", block_cv)]:
128
+ d = scv.fold_diagnostics(cv, features, prediction_points=grid, coords=coords)
129
+ print(
130
+ f"{name:<18} median test->train {np.median(d.Gjstar):6.0f} m | "
131
+ f"prediction->train {np.median(d.Gij):6.0f} m | W = {d.W:.0f}"
132
+ )
133
+ ```
134
+
135
+ Output (scikit-learn 1.9, numpy 2.5):
136
+
137
+ ```text
138
+ range = 2760 m, model = spherical, nugget = 0.00
139
+ random KFold R2 = 0.835 +/- 0.041
140
+ SpatialBlockKFold R2 = 0.143 +/- 0.299
141
+ best: {'max_features': 0.3} R2 = 0.225
142
+ random KFold median test->train 122 m | prediction->train 694 m | W = 711
143
+ SpatialBlockKFold median test->train 827 m | prediction->train 694 m | W = 201
144
+ ```
145
+
146
+ Random K-fold tests points that are typically 122 m from a training sample, while the map
147
+ is predicted 694 m (median) from the nearest sample: its R² of 0.84 measures something the
148
+ model will not be asked to do.
149
+
150
+ ## Which CV matches the true map error?
151
+
152
+ [`examples/compare_cv.py`](examples/compare_cv.py) generates 300 clustered samples of a
153
+ spatial field together with the *true* values on a 40 × 40 prediction grid over the whole
154
+ 10 km × 10 km area, so the real map RMSE is known. Pooled CV predictions
155
+ (`cross_val_predict`) give:
156
+
157
+ ```text
158
+ autocorrelation range of y: 2177 m (spherical)
159
+ true map RMSE: 1.035
160
+
161
+ splitter CV RMSE error W
162
+ KFold (random) 0.499 -0.537 974
163
+ SpatialBlockKFold 1.097 +0.062 482
164
+ SpatialClusterKFold 1.173 +0.138 403
165
+ SpatialKFoldWithBuffer 1.216 +0.180 552
166
+ BufferedLeaveOneOut 1.093 +0.058 760
167
+ NNDMLeaveOneOut 0.946 -0.089 71
168
+ KNNDMKFold 1.210 +0.175 341
169
+ ```
170
+
171
+ Random CV halves the true error. The spatial methods land within ±0.2, and NNDM has by far
172
+ the best-matched distance distribution (lowest W). Spatial CV is not automatically
173
+ "correct" either: strategies that hold out more than the prediction task requires are
174
+ pessimistic (Wadoux et al. 2021), which is why matching the distance distributions
175
+ (NNDM / kNNDM) and checking them with `fold_diagnostics` is worthwhile.
176
+
177
+ ## Coordinates
178
+
179
+ Splitters look up sample coordinates in this order:
180
+
181
+ 1. `coords=`: an `(n_samples, 2)` array, or a GeoSeries / GeoDataFrame (point
182
+ coordinates, or centroids for other geometries);
183
+ 2. `coord_columns=`: two column names (DataFrame) or positions inside `X`;
184
+ 3. `X` itself when it is a GeoDataFrame;
185
+ 4. `X` itself when it has exactly two columns.
186
+
187
+ Units: with `metric="euclidean"` (default) coordinates are planar, e.g. metres in a
188
+ projected CRS, and `block_size` / `buffer` use the same unit. With `metric="haversine"`
189
+ coordinates are `(lon, lat)` in degrees and all distances (`buffer`, `block_size`, `phi`,
190
+ variogram range, diagnostics) are great-circle metres. K-means clustering then runs on 3D
191
+ unit vectors, so clusters are correct across the antimeridian; blocks are laid on a local
192
+ equirectangular projection.
193
+
194
+ ```python
195
+ cv = scv.SpatialKFoldWithBuffer(5, buffer=2_000, coords=lonlat, metric="haversine")
196
+ cv = scv.SpatialClusterKFold(5, coord_columns=["x", "y"]) # columns of a DataFrame X
197
+ cv = scv.SpatialBlockKFold(5, block_size=5_000) # X is a GeoDataFrame
198
+ ```
199
+
200
+ ## Splitter notes
201
+
202
+ **Blocks.** `block_size` should be at least the autocorrelation range. Hexagons have the
203
+ same area as squares of side `block_size`. `assignment="systematic"` numbers grid cells row
204
+ by row and sends cell *i* to fold *i* mod `n_splits` (deterministic); `balance=True`
205
+ (random assignment) gives each block, largest first, to the fold with the fewest samples.
206
+ `block_index(X)` returns the block id of each sample, and every partitioning splitter has
207
+ `assign_folds(X)`.
208
+
209
+ **Buffers.** A training point is dropped when its distance to *any* test point is
210
+ `<= buffer`. Test sets still partition the data, so `cross_val_predict` works; training
211
+ sets shrink, so very large buffers raise an error rather than yield empty training sets.
212
+ Wrap any splitter: `SpatialKFoldWithBuffer(buffer=1000, cv=SpatialBlockKFold(...))`.
213
+
214
+ **NNDM** (Milà et al. 2022) needs the locations where the model will be applied
215
+ (`prediction_points`, e.g. a regular sample of a few thousand points of the area of
216
+ interest). Starting from LOO, it walks through nearest-neighbour distances `r` in
217
+ increasing order and removes test point *j*'s nearest neighbour from its training set
218
+ whenever the LOO distance CDF `Gj*(r)` would still be at or above the prediction CDF
219
+ `Gij(r)`, keeping more than `min_train` of the data and stopping at `phi` (default: the
220
+ largest distance). It follows CAST's reference `nndm()`. Cost is O(n²) memory.
221
+
222
+ **kNNDM** (Linnenbrink et al. 2024) clusters the samples (Ward hierarchical clustering, or
223
+ k-means) into *q* clusters for a log-spaced grid of *q* between `n_splits` and *n* − 2.
224
+ Clusters with at least *n*/*k* samples become folds on their own; the rest, ordered along
225
+ the first principal component of the coordinates, are dealt round-robin into the remaining
226
+ folds. Configurations with a fold larger than `maxp` are rejected and the one with the
227
+ smallest Wasserstein distance W between `Gj*` and `Gij` wins. If random CV already matches
228
+ (W < 1e-4) random folds are returned. It follows CAST's reference `knndm()`
229
+ (geographical space).
230
+
231
+ ```python
232
+ res = scv.knndm(coords, grid, n_splits=5, random_state=0)
233
+ res.labels, res.n_clusters, res.W, res.method # folds, chosen q, W, "hierarchical"
234
+ res = scv.nndm(coords, grid, phi=3_000)
235
+ res.train_indices[0], res.Gjstar, res.W
236
+ ```
237
+
238
+ ## Diagnostics and plots
239
+
240
+ ```python
241
+ d = scv.fold_diagnostics(cv, X, prediction_points=grid, coords=coords)
242
+ print(d) # medians of Gij / Gjstar / Gj, W and W_random
243
+ d.plot() # ECDFs of the three distance distributions
244
+ scv.plot_folds(cv, X) # colour = test fold
245
+ scv.plot_folds(cv, X, fold=0) # train / test / excluded (buffer) points of split 0
246
+ vg.plot() # empirical semivariogram and fitted model
247
+ ```
248
+
249
+ `fold_diagnostics` works with any object that has `split`, including scikit-learn's own
250
+ splitters, so you can check a `GroupKFold` built from administrative units too.
251
+
252
+ ## Variogram details
253
+
254
+ `estimate_autocorrelation_range(coords, values, model="spherical" | "exponential" | "auto")`
255
+ computes Matheron's estimator in `n_lags` equal-width bins up to `max_lag` (default: a
256
+ third of the bounding-box diagonal) and fits nugget + partial sill + range by weighted
257
+ least squares with weights N(h)/h² (Cressie 1985). The returned `Variogram` has
258
+ `effective_range` (the range for the spherical model, 3 × the range parameter for the
259
+ exponential one, i.e. 95 % of the sill), `nugget`, `partial_sill`, `sill`, the empirical
260
+ `lags`/`gamma`/`counts`, and is callable: `vg(h)`. Use model residuals as `values` when
261
+ possible; above 2000 samples a random subset is used (`max_points`).
262
+
263
+ ## References
264
+
265
+ - Brenning, A. (2012). Spatial cross-validation and bootstrap for the assessment of
266
+ prediction rules in remote sensing: the R package sperrorest. *IEEE IGARSS 2012*,
267
+ 5372–5375. https://doi.org/10.1109/IGARSS.2012.6352393
268
+ - Cressie, N. (1985). Fitting variogram models by weighted least squares. *Mathematical
269
+ Geology* 17, 563–586. https://doi.org/10.1007/BF01032109
270
+ - Le Rest, K., Pinaud, D., Monestiez, P., Chadoeuf, J., Bretagnolle, V. (2014). Spatial
271
+ leave-one-out cross-validation for variable selection in the presence of spatial
272
+ autocorrelation. *Global Ecology and Biogeography* 23, 811–820.
273
+ https://doi.org/10.1111/geb.12161
274
+ - Linnenbrink, J., Milà, C., Ludwig, M., Meyer, H. (2024). kNNDM CV: k-fold
275
+ nearest-neighbour distance matching cross-validation for map accuracy estimation.
276
+ *Geoscientific Model Development* 17, 5897–5912.
277
+ https://doi.org/10.5194/gmd-17-5897-2024
278
+ - Milà, C., Mateu, J., Pebesma, E., Meyer, H. (2022). Nearest neighbour distance matching
279
+ Leave-One-Out Cross-Validation for map validation. *Methods in Ecology and Evolution*
280
+ 13, 1304–1316. https://doi.org/10.1111/2041-210X.13851
281
+ - Ploton, P., et al. (2020). Spatial validation reveals poor predictive performance of
282
+ large-scale ecological mapping models. *Nature Communications* 11, 4540.
283
+ https://doi.org/10.1038/s41467-020-18321-y
284
+ - Pohjankukka, J., Pahikkala, T., Nevalainen, P., Heikkonen, J. (2017). Estimating the
285
+ prediction performance of spatial models via spatial k-fold cross validation.
286
+ *International Journal of Geographical Information Science* 31, 2001–2019.
287
+ https://doi.org/10.1080/13658816.2017.1346255
288
+ - Rahimi, A., Recht, B. (2007). Random features for large-scale kernel machines.
289
+ *Advances in Neural Information Processing Systems* 20 (used by the synthetic data
290
+ generator).
291
+ - Roberts, D. R., et al. (2017). Cross-validation strategies for data with temporal,
292
+ spatial, hierarchical, or phylogenetic structure. *Ecography* 40, 913–929.
293
+ https://doi.org/10.1111/ecog.02881
294
+ - Valavi, R., Elith, J., Lahoz-Monfort, J. J., Guillera-Arroita, G. (2019). blockCV: an R
295
+ package for generating spatially or environmentally separated folds for k-fold
296
+ cross-validation of species distribution models. *Methods in Ecology and Evolution* 10,
297
+ 225–232. https://doi.org/10.1111/2041-210X.13107
298
+ - Wadoux, A. M. J.-C., Heuvelink, G. B. M., de Bruin, S., Brus, D. J. (2021). Spatial
299
+ cross-validation is not the right way to evaluate map accuracy. *Ecological Modelling*
300
+ 457, 109692. https://doi.org/10.1016/j.ecolmodel.2021.109692
301
+
302
+ The NNDM and kNNDM implementations follow the R package CAST (Meyer, Milà, Ludwig,
303
+ Linnenbrink et al.); block and buffer designs follow blockCV and spatialsample.
304
+
305
+ ## Citation
306
+
307
+ See [`CITATION.cff`](CITATION.cff). Please also cite the papers of the methods you use.
308
+
309
+ ## License
310
+
311
+ MIT © Yusuf Eminoğlu
@@ -0,0 +1,17 @@
1
+ spatialcv/__init__.py,sha256=dW7mKKzln6IYPwqHREm3s0zOxz515eVzmLOkUnhbG6w,1622
2
+ spatialcv/_base.py,sha256=KWUXZUsmrKhbs7tjEMkm1yu-Y7bq7gf9SpD0bGfOiWw,4112
3
+ spatialcv/_coords.py,sha256=BW-gd_ndbwAeJPBq9w22OLrnxH4D8auvPuAkq9uB20Q,7867
4
+ spatialcv/blocks.py,sha256=X7_kvkkoKyig-y60hXRCEMADB_36dIfDoK9Dsox90PM,6841
5
+ spatialcv/buffer.py,sha256=TP6t2lloAJHWiUH8DTPsVmOsz4v9y5edO9E4O4lrYq4,5563
6
+ spatialcv/cluster.py,sha256=SPRBelmjGPITKkoBWPKVFQwKSK2Ody3J45epRnYkvR0,5548
7
+ spatialcv/datasets.py,sha256=oYucJRCyKwel_PS_um-a4HttCF8YZ7e__Jdn8h-iun4,3934
8
+ spatialcv/diagnostics.py,sha256=gtlbP_Qu1TGmmnLDtEZb_Ts4Kucox4OaxExR8atybGw,5340
9
+ spatialcv/nndm.py,sha256=sHU-4Z1bfNfGbAgtrer_myKtnK-h6ixkX0MgQY-1Bp0,17932
10
+ spatialcv/plotting.py,sha256=7dWmHAH53b9DuYNsgGd21Ae5XdzF2oKiHL93B4ugTBY,2943
11
+ spatialcv/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ spatialcv/variogram.py,sha256=ntS3u5klG-Eerf_mhWCpIgNCJHUEQMI1rWznH0bXt7w,9950
13
+ spatialcv-0.1.0.dist-info/licenses/LICENSE,sha256=Z9meOk3opsRgHOqC5mA4B8NjZoU5WU1Kqhn82FL4ICs,1072
14
+ spatialcv-0.1.0.dist-info/METADATA,sha256=q16USxEQw1uS8KzLqDSFMs8PXFxkiih7DHflKr93Exg,16976
15
+ spatialcv-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
16
+ spatialcv-0.1.0.dist-info/top_level.txt,sha256=GCp6bgxq3Njy1wGWbqCW-T69be6NUkIhwkhx7mNycTM,10
17
+ spatialcv-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yusuf Eminoğlu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ spatialcv