spatialsample 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.
- spatialsample/__init__.py +89 -0
- spatialsample/_frame.py +91 -0
- spatialsample/_types.py +36 -0
- spatialsample/accuracy.py +232 -0
- spatialsample/balance.py +144 -0
- spatialsample/bas.py +96 -0
- spatialsample/cli.py +184 -0
- spatialsample/designs.py +562 -0
- spatialsample/estimators.py +222 -0
- spatialsample/geometry.py +253 -0
- spatialsample/grts.py +175 -0
- spatialsample/io.py +161 -0
- spatialsample/lpm.py +133 -0
- spatialsample/probabilities.py +202 -0
- spatialsample/py.typed +0 -0
- spatialsample/raster.py +170 -0
- spatialsample/sample.py +213 -0
- spatialsample-0.1.0.dist-info/METADATA +309 -0
- spatialsample-0.1.0.dist-info/RECORD +23 -0
- spatialsample-0.1.0.dist-info/WHEEL +5 -0
- spatialsample-0.1.0.dist-info/entry_points.txt +2 -0
- spatialsample-0.1.0.dist-info/licenses/LICENSE +21 -0
- spatialsample-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""spatialsample: design-based spatial sampling in pure NumPy.
|
|
2
|
+
|
|
3
|
+
Spatially balanced designs (GRTS, BAS, LPM), classical designs (simple
|
|
4
|
+
random, systematic, stratified, cluster, two-stage, transects, Poisson-disk),
|
|
5
|
+
inclusion probabilities and design weights, Horvitz-Thompson estimation with
|
|
6
|
+
local-neighbourhood variance, the Voronoi spatial-balance metric and map
|
|
7
|
+
accuracy assessment following Olofsson et al. (2014).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
from .accuracy import AccuracyAssessment, allocate_accuracy_sample, olofsson, sample_size
|
|
15
|
+
from .balance import BalanceResult, compare_designs, spatial_balance, voronoi_balance
|
|
16
|
+
from .bas import bas, halton
|
|
17
|
+
from .designs import (
|
|
18
|
+
cluster,
|
|
19
|
+
points_along_lines,
|
|
20
|
+
poisson_disk,
|
|
21
|
+
random_transects,
|
|
22
|
+
srs,
|
|
23
|
+
stratified,
|
|
24
|
+
stratified_pixels,
|
|
25
|
+
systematic,
|
|
26
|
+
two_stage,
|
|
27
|
+
)
|
|
28
|
+
from .estimators import (
|
|
29
|
+
Estimate,
|
|
30
|
+
estimate,
|
|
31
|
+
ht_mean,
|
|
32
|
+
ht_total,
|
|
33
|
+
local_variance,
|
|
34
|
+
local_weights,
|
|
35
|
+
srs_variance,
|
|
36
|
+
)
|
|
37
|
+
from .geometry import Polygon, Region
|
|
38
|
+
from .grts import grts, grts_order, reverse_hierarchical_order
|
|
39
|
+
from .io import read_lines, read_matrix_csv, read_points, read_region, read_strata
|
|
40
|
+
from .lpm import lpm, lpm2_select
|
|
41
|
+
from .probabilities import allocate, inclusion_probabilities
|
|
42
|
+
from .raster import RasterMask
|
|
43
|
+
from .sample import Sample
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"__version__",
|
|
47
|
+
"AccuracyAssessment",
|
|
48
|
+
"BalanceResult",
|
|
49
|
+
"Estimate",
|
|
50
|
+
"Polygon",
|
|
51
|
+
"RasterMask",
|
|
52
|
+
"Region",
|
|
53
|
+
"Sample",
|
|
54
|
+
"allocate",
|
|
55
|
+
"allocate_accuracy_sample",
|
|
56
|
+
"bas",
|
|
57
|
+
"cluster",
|
|
58
|
+
"compare_designs",
|
|
59
|
+
"estimate",
|
|
60
|
+
"grts",
|
|
61
|
+
"grts_order",
|
|
62
|
+
"halton",
|
|
63
|
+
"ht_mean",
|
|
64
|
+
"ht_total",
|
|
65
|
+
"inclusion_probabilities",
|
|
66
|
+
"local_variance",
|
|
67
|
+
"local_weights",
|
|
68
|
+
"lpm",
|
|
69
|
+
"lpm2_select",
|
|
70
|
+
"olofsson",
|
|
71
|
+
"points_along_lines",
|
|
72
|
+
"poisson_disk",
|
|
73
|
+
"random_transects",
|
|
74
|
+
"read_lines",
|
|
75
|
+
"read_matrix_csv",
|
|
76
|
+
"read_points",
|
|
77
|
+
"read_region",
|
|
78
|
+
"read_strata",
|
|
79
|
+
"reverse_hierarchical_order",
|
|
80
|
+
"sample_size",
|
|
81
|
+
"spatial_balance",
|
|
82
|
+
"srs",
|
|
83
|
+
"srs_variance",
|
|
84
|
+
"stratified",
|
|
85
|
+
"stratified_pixels",
|
|
86
|
+
"systematic",
|
|
87
|
+
"two_stage",
|
|
88
|
+
"voronoi_balance",
|
|
89
|
+
]
|
spatialsample/_frame.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Internal helpers: resolve inputs into a domain or a finite frame, build grid frames."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Any, Callable, Mapping, Optional, Tuple, Union
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from ._types import Domain, FloatArray
|
|
11
|
+
from .geometry import Region
|
|
12
|
+
from .raster import RasterMask
|
|
13
|
+
|
|
14
|
+
FrameInput = Union[Domain, FloatArray, Mapping[str, Any]]
|
|
15
|
+
ProbLike = Optional[Union[FloatArray, Callable[[FloatArray], Any]]]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def as_domain(obj: Any) -> Domain:
|
|
19
|
+
"""Coerce a Region, RasterMask, GeoJSON mapping or ``__geo_interface__`` object."""
|
|
20
|
+
if isinstance(obj, (Region, RasterMask)):
|
|
21
|
+
return obj
|
|
22
|
+
if isinstance(obj, Mapping) or hasattr(obj, "__geo_interface__"):
|
|
23
|
+
return Region.from_geojson(obj)
|
|
24
|
+
if all(hasattr(obj, a) for a in ("area", "bounds", "contains", "random_points")):
|
|
25
|
+
return obj # duck-typed custom domain
|
|
26
|
+
raise TypeError(
|
|
27
|
+
"expected a Region, RasterMask, GeoJSON mapping or object with __geo_interface__"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve(obj: Any) -> Tuple[Optional[Domain], Optional[FloatArray]]:
|
|
32
|
+
"""Return ``(domain, None)`` for areal input or ``(None, points)`` for a finite frame."""
|
|
33
|
+
if isinstance(obj, (Region, RasterMask, Mapping)) or hasattr(obj, "__geo_interface__"):
|
|
34
|
+
return as_domain(obj), None
|
|
35
|
+
if all(hasattr(obj, a) for a in ("area", "bounds", "contains", "random_points")):
|
|
36
|
+
return obj, None
|
|
37
|
+
pts = np.asarray(obj, dtype=float)
|
|
38
|
+
if pts.ndim != 2 or pts.shape[1] < 2:
|
|
39
|
+
raise ValueError("a finite frame must be an (N, 2) array of coordinates")
|
|
40
|
+
return None, pts[:, :2]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def grid_frame(
|
|
44
|
+
domain: Domain, size: int, rng: np.random.Generator, min_points: int = 1
|
|
45
|
+
) -> Tuple[FloatArray, float]:
|
|
46
|
+
"""Square-grid frame of roughly ``size`` cell centres inside ``domain``.
|
|
47
|
+
|
|
48
|
+
The grid has a uniformly random offset, so every location in the domain is
|
|
49
|
+
equally likely to lie in any cell. Returns ``(points, spacing)``.
|
|
50
|
+
"""
|
|
51
|
+
h = math.sqrt(domain.area / max(size, 1))
|
|
52
|
+
x0, y0, x1, y1 = domain.bounds
|
|
53
|
+
for _ in range(12):
|
|
54
|
+
off = rng.uniform(0.0, h, 2)
|
|
55
|
+
xs = x0 - off[0] + h * (np.arange(int(math.ceil((x1 - x0) / h)) + 2) + 0.5) - h
|
|
56
|
+
ys = y0 - off[1] + h * (np.arange(int(math.ceil((y1 - y0) / h)) + 2) + 0.5) - h
|
|
57
|
+
gx, gy = np.meshgrid(xs, ys)
|
|
58
|
+
pts = np.column_stack([gx.ravel(), gy.ravel()])
|
|
59
|
+
pts = pts[domain.contains(pts)]
|
|
60
|
+
if len(pts) >= max(min_points, size // 2):
|
|
61
|
+
return pts, h
|
|
62
|
+
h /= math.sqrt(2.0)
|
|
63
|
+
raise ValueError("could not build a grid frame; the domain may be too thin")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def jitter_in_cells(
|
|
67
|
+
centres: FloatArray, h: float, domain: Domain, rng: np.random.Generator, tries: int = 50
|
|
68
|
+
) -> FloatArray:
|
|
69
|
+
"""Move each grid centre to a uniform location in its cell that lies in the domain."""
|
|
70
|
+
out = centres.copy()
|
|
71
|
+
todo = np.arange(len(centres))
|
|
72
|
+
for _ in range(tries):
|
|
73
|
+
if len(todo) == 0:
|
|
74
|
+
break
|
|
75
|
+
cand = centres[todo] + rng.uniform(-h / 2, h / 2, (len(todo), 2))
|
|
76
|
+
ok = domain.contains(cand)
|
|
77
|
+
out[todo[ok]] = cand[ok]
|
|
78
|
+
todo = todo[~ok]
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def frame_sizes(prob: ProbLike, xy: FloatArray) -> FloatArray:
|
|
83
|
+
if prob is None:
|
|
84
|
+
return np.ones(len(xy))
|
|
85
|
+
if callable(prob):
|
|
86
|
+
sizes = np.asarray(prob(xy), dtype=float).ravel()
|
|
87
|
+
else:
|
|
88
|
+
sizes = np.asarray(prob, dtype=float).ravel()
|
|
89
|
+
if len(sizes) != len(xy):
|
|
90
|
+
raise ValueError("prob must give one value per frame unit")
|
|
91
|
+
return sizes
|
spatialsample/_types.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Shared type aliases and small helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional, Tuple, Union
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
try: # Protocol is in typing from 3.8
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
except ImportError: # pragma: no cover
|
|
12
|
+
from typing_extensions import Protocol # type: ignore[assignment]
|
|
13
|
+
|
|
14
|
+
FloatArray = np.ndarray
|
|
15
|
+
SeedLike = Optional[Union[int, np.random.Generator, np.random.SeedSequence]]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def as_rng(seed: SeedLike) -> np.random.Generator:
|
|
19
|
+
"""Return a NumPy ``Generator`` from an int, a generator or ``None``."""
|
|
20
|
+
if isinstance(seed, np.random.Generator):
|
|
21
|
+
return seed
|
|
22
|
+
return np.random.default_rng(seed)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Domain(Protocol):
|
|
26
|
+
"""Anything that can be sampled continuously: a polygon region or a raster mask."""
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def area(self) -> float: ...
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def bounds(self) -> Tuple[float, float, float, float]: ...
|
|
33
|
+
|
|
34
|
+
def contains(self, xy: Any) -> np.ndarray: ...
|
|
35
|
+
|
|
36
|
+
def random_points(self, n: int, seed: SeedLike = None) -> FloatArray: ...
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Map accuracy assessment and area estimation, Olofsson et al. (2014).
|
|
2
|
+
|
|
3
|
+
Good-practice estimators for a stratified random sample in which the strata
|
|
4
|
+
are the map classes. Given the error matrix of *sample counts* (rows = map
|
|
5
|
+
class, columns = reference class) and the mapped area of each class, the
|
|
6
|
+
functions return unbiased (stratified) estimates of overall, user's and
|
|
7
|
+
producer's accuracy and of the area of each reference class, all with
|
|
8
|
+
standard errors and confidence intervals. :func:`sample_size` implements
|
|
9
|
+
equation 13 of the paper (Cochran 1977, eq. 5.25) for a target standard error
|
|
10
|
+
of overall accuracy.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from statistics import NormalDist
|
|
18
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from .probabilities import allocate
|
|
23
|
+
|
|
24
|
+
__all__ = ["olofsson", "AccuracyAssessment", "sample_size", "allocate_accuracy_sample"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class AccuracyAssessment:
|
|
29
|
+
"""Stratified estimates from an error matrix (Olofsson et al. 2014)."""
|
|
30
|
+
|
|
31
|
+
labels: List[str]
|
|
32
|
+
counts: np.ndarray
|
|
33
|
+
mapped_area: np.ndarray
|
|
34
|
+
proportions: np.ndarray
|
|
35
|
+
overall: float
|
|
36
|
+
overall_se: float
|
|
37
|
+
users: np.ndarray
|
|
38
|
+
users_se: np.ndarray
|
|
39
|
+
producers: np.ndarray
|
|
40
|
+
producers_se: np.ndarray
|
|
41
|
+
area_proportion: np.ndarray
|
|
42
|
+
area_proportion_se: np.ndarray
|
|
43
|
+
area: np.ndarray
|
|
44
|
+
area_se: np.ndarray
|
|
45
|
+
conf: float = 0.95
|
|
46
|
+
z: float = field(default=1.959963984540054)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def area_ci(self) -> np.ndarray:
|
|
50
|
+
"""Half-width of the confidence interval for each class area."""
|
|
51
|
+
return self.z * self.area_se
|
|
52
|
+
|
|
53
|
+
def ci(self, se: Any) -> Any:
|
|
54
|
+
return self.z * np.asarray(se)
|
|
55
|
+
|
|
56
|
+
def table(self) -> List[Dict[str, Any]]:
|
|
57
|
+
"""One row per class with all estimates and CI half-widths."""
|
|
58
|
+
rows = []
|
|
59
|
+
for i, lab in enumerate(self.labels):
|
|
60
|
+
rows.append(
|
|
61
|
+
{
|
|
62
|
+
"class": lab,
|
|
63
|
+
"mapped_area": float(self.mapped_area[i]),
|
|
64
|
+
"area": float(self.area[i]),
|
|
65
|
+
"area_se": float(self.area_se[i]),
|
|
66
|
+
"area_ci": float(self.z * self.area_se[i]),
|
|
67
|
+
"users": float(self.users[i]),
|
|
68
|
+
"users_ci": float(self.z * self.users_se[i]),
|
|
69
|
+
"producers": float(self.producers[i]),
|
|
70
|
+
"producers_ci": float(self.z * self.producers_se[i]),
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
return rows
|
|
74
|
+
|
|
75
|
+
def summary(self, digits: int = 3) -> str:
|
|
76
|
+
"""Plain-text report."""
|
|
77
|
+
pct = int(round(self.conf * 100))
|
|
78
|
+
head = (
|
|
79
|
+
f"{'class':<20}{'mapped area':>14}{'est. area':>14}{'+/- ' + str(pct) + '% CI':>14}"
|
|
80
|
+
f"{'user':>9}{'+/-':>8}{'producer':>10}{'+/-':>8}"
|
|
81
|
+
)
|
|
82
|
+
lines = [head, "-" * len(head)]
|
|
83
|
+
for r in self.table():
|
|
84
|
+
lines.append(
|
|
85
|
+
f"{str(r['class']):<20}{r['mapped_area']:>14,.{0}f}{r['area']:>14,.{0}f}"
|
|
86
|
+
f"{r['area_ci']:>14,.{0}f}{r['users']:>9.{digits}f}{r['users_ci']:>8.{digits}f}"
|
|
87
|
+
f"{r['producers']:>10.{digits}f}{r['producers_ci']:>8.{digits}f}"
|
|
88
|
+
)
|
|
89
|
+
lines.append(
|
|
90
|
+
f"overall accuracy {self.overall:.{digits}f} +/- {self.z * self.overall_se:.{digits}f}"
|
|
91
|
+
f" ({pct}% CI), n = {int(self.counts.sum())}"
|
|
92
|
+
)
|
|
93
|
+
return "\n".join(lines)
|
|
94
|
+
|
|
95
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
96
|
+
return {
|
|
97
|
+
"labels": list(self.labels),
|
|
98
|
+
"conf": self.conf,
|
|
99
|
+
"overall": self.overall,
|
|
100
|
+
"overall_se": self.overall_se,
|
|
101
|
+
"classes": self.table(),
|
|
102
|
+
"proportions": self.proportions.tolist(),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def olofsson(
|
|
107
|
+
matrix: Any,
|
|
108
|
+
mapped_area: Sequence[float],
|
|
109
|
+
labels: Optional[Sequence[str]] = None,
|
|
110
|
+
conf: float = 0.95,
|
|
111
|
+
) -> AccuracyAssessment:
|
|
112
|
+
"""Accuracy and area estimates from an error matrix of sample counts.
|
|
113
|
+
|
|
114
|
+
Parameters
|
|
115
|
+
----------
|
|
116
|
+
matrix:
|
|
117
|
+
``(q, q)`` sample counts ``n_ij``: rows are map classes (strata),
|
|
118
|
+
columns are reference classes, in the same class order.
|
|
119
|
+
mapped_area:
|
|
120
|
+
Mapped area (or pixel count) of each map class; estimated areas are
|
|
121
|
+
returned in the same unit.
|
|
122
|
+
labels:
|
|
123
|
+
Class names (default ``"1"`` ... ``"q"``).
|
|
124
|
+
conf:
|
|
125
|
+
Confidence level for intervals (default 0.95, ``z = 1.96``).
|
|
126
|
+
"""
|
|
127
|
+
n = np.asarray(matrix, dtype=float)
|
|
128
|
+
if n.ndim != 2 or n.shape[0] != n.shape[1]:
|
|
129
|
+
raise ValueError("the error matrix must be square")
|
|
130
|
+
q = n.shape[0]
|
|
131
|
+
am = np.asarray(mapped_area, dtype=float).ravel()
|
|
132
|
+
if len(am) != q:
|
|
133
|
+
raise ValueError("need one mapped area per map class")
|
|
134
|
+
if np.any(n < 0) or np.any(am < 0):
|
|
135
|
+
raise ValueError("counts and areas must be non-negative")
|
|
136
|
+
ni = n.sum(axis=1)
|
|
137
|
+
if np.any(ni < 2):
|
|
138
|
+
raise ValueError("every map class needs at least two sample units")
|
|
139
|
+
a_tot = am.sum()
|
|
140
|
+
w = am / a_tot
|
|
141
|
+
frac = n / ni[:, None] # n_ij / n_i.
|
|
142
|
+
p = w[:, None] * frac # estimated cell proportions
|
|
143
|
+
diag = np.diag(p)
|
|
144
|
+
overall = float(diag.sum())
|
|
145
|
+
users = np.diag(frac)
|
|
146
|
+
users_var = users * (1 - users) / (ni - 1)
|
|
147
|
+
overall_var = float(np.sum(w**2 * users_var))
|
|
148
|
+
p_col = p.sum(axis=0)
|
|
149
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
150
|
+
producers = np.where(p_col > 0, diag / p_col, np.nan)
|
|
151
|
+
# Producer's accuracy variance (Olofsson et al. 2014, eq. 7; Card 1982)
|
|
152
|
+
n_col_hat = (am[:, None] / ni[:, None] * n).sum(axis=0) # estimated reference totals
|
|
153
|
+
cell_var = frac * (1 - frac) / (ni[:, None] - 1)
|
|
154
|
+
prod_var = np.full(q, np.nan)
|
|
155
|
+
for j in range(q):
|
|
156
|
+
if n_col_hat[j] <= 0:
|
|
157
|
+
continue
|
|
158
|
+
other = sum(am[i] ** 2 * cell_var[i, j] for i in range(q) if i != j)
|
|
159
|
+
term = am[j] ** 2 * (1 - producers[j]) ** 2 * users_var[j] + producers[j] ** 2 * other
|
|
160
|
+
prod_var[j] = term / n_col_hat[j] ** 2
|
|
161
|
+
area_prop = p_col
|
|
162
|
+
area_prop_var = np.sum(w[:, None] ** 2 * cell_var, axis=0) # eq. 10
|
|
163
|
+
z = NormalDist().inv_cdf(0.5 + conf / 2)
|
|
164
|
+
return AccuracyAssessment(
|
|
165
|
+
labels=[str(s) for s in (labels or [str(i + 1) for i in range(q)])],
|
|
166
|
+
counts=n,
|
|
167
|
+
mapped_area=am,
|
|
168
|
+
proportions=p,
|
|
169
|
+
overall=overall,
|
|
170
|
+
overall_se=math.sqrt(overall_var),
|
|
171
|
+
users=users,
|
|
172
|
+
users_se=np.sqrt(users_var),
|
|
173
|
+
producers=producers,
|
|
174
|
+
producers_se=np.sqrt(prod_var),
|
|
175
|
+
area_proportion=area_prop,
|
|
176
|
+
area_proportion_se=np.sqrt(area_prop_var),
|
|
177
|
+
area=area_prop * a_tot,
|
|
178
|
+
area_se=np.sqrt(area_prop_var) * a_tot,
|
|
179
|
+
conf=conf,
|
|
180
|
+
z=z,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def sample_size(
|
|
185
|
+
mapped_area: Sequence[float], expected_users: Sequence[float], target_se: float = 0.01
|
|
186
|
+
) -> int:
|
|
187
|
+
"""Total sample size for a target standard error of overall accuracy.
|
|
188
|
+
|
|
189
|
+
``n = (sum_i W_i S_i / S(O))^2`` with ``S_i = sqrt(U_i (1 - U_i))``, where
|
|
190
|
+
``W_i`` are mapped area proportions and ``U_i`` conjectured user's
|
|
191
|
+
accuracies (Olofsson et al. 2014, eq. 13). Rounded up.
|
|
192
|
+
"""
|
|
193
|
+
am = np.asarray(mapped_area, dtype=float)
|
|
194
|
+
u = np.asarray(expected_users, dtype=float)
|
|
195
|
+
if am.shape != u.shape:
|
|
196
|
+
raise ValueError("need one expected user's accuracy per class")
|
|
197
|
+
if target_se <= 0:
|
|
198
|
+
raise ValueError("target_se must be positive")
|
|
199
|
+
w = am / am.sum()
|
|
200
|
+
s = np.sqrt(u * (1 - u))
|
|
201
|
+
return int(math.ceil((np.sum(w * s) / target_se) ** 2 - 1e-9))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def allocate_accuracy_sample(
|
|
205
|
+
n: int,
|
|
206
|
+
mapped_area: Sequence[float],
|
|
207
|
+
method: str = "proportional",
|
|
208
|
+
min_per_class: int = 0,
|
|
209
|
+
rare: Optional[Dict[int, int]] = None,
|
|
210
|
+
) -> List[int]:
|
|
211
|
+
"""Allocate ``n`` among map classes.
|
|
212
|
+
|
|
213
|
+
``method`` is ``"proportional"`` or ``"equal"``. ``rare`` fixes the size of
|
|
214
|
+
selected classes (index -> n), as Olofsson et al. (2014) recommend for
|
|
215
|
+
small change classes (e.g. 50-100 units each), and the remainder is
|
|
216
|
+
allocated proportionally among the other classes.
|
|
217
|
+
"""
|
|
218
|
+
am = [float(a) for a in mapped_area]
|
|
219
|
+
if not rare:
|
|
220
|
+
return list(allocate(n, am, method, min_per_stratum=min_per_class))
|
|
221
|
+
fixed = {int(k): int(v) for k, v in rare.items()}
|
|
222
|
+
rest_idx = [i for i in range(len(am)) if i not in fixed]
|
|
223
|
+
left = n - sum(fixed.values())
|
|
224
|
+
if left < 0:
|
|
225
|
+
raise ValueError("fixed class sizes exceed n")
|
|
226
|
+
rest = allocate(left, [am[i] for i in rest_idx], method, min_per_stratum=min_per_class)
|
|
227
|
+
out = [0] * len(am)
|
|
228
|
+
for i, v in fixed.items():
|
|
229
|
+
out[i] = v
|
|
230
|
+
for i, v in zip(rest_idx, rest):
|
|
231
|
+
out[i] = int(v)
|
|
232
|
+
return out
|
spatialsample/balance.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Voronoi spatial-balance metric of Stevens & Olsen (2004).
|
|
2
|
+
|
|
3
|
+
For each sample site ``i`` let ``v_i`` be the total inclusion probability of
|
|
4
|
+
the frame units in the site's Voronoi polygon (the units nearer to ``i`` than
|
|
5
|
+
to any other site). In a perfectly balanced sample every ``v_i`` equals one.
|
|
6
|
+
The metric is the variance ``mean((v_i - 1)^2)``; smaller is better. Simple
|
|
7
|
+
random samples typically score around 0.3; GRTS, BAS and LPM designs are
|
|
8
|
+
markedly lower.
|
|
9
|
+
|
|
10
|
+
Voronoi polygons are evaluated on a dense frame (a fine grid for continuous
|
|
11
|
+
domains), so no polygon clipping is required.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any, Dict, Mapping, Optional
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from ._frame import as_domain, grid_frame
|
|
22
|
+
from ._types import SeedLike, as_rng
|
|
23
|
+
from .probabilities import nearest_index
|
|
24
|
+
from .sample import Sample
|
|
25
|
+
|
|
26
|
+
__all__ = ["voronoi_balance", "spatial_balance", "BalanceResult", "compare_designs"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class BalanceResult:
|
|
31
|
+
"""Voronoi balance of a sample."""
|
|
32
|
+
|
|
33
|
+
value: float
|
|
34
|
+
v: np.ndarray
|
|
35
|
+
n: int
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def max_deviation(self) -> float:
|
|
39
|
+
return float(np.max(np.abs(self.v - 1.0))) if self.n else float("nan")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def voronoi_balance(sample_xy: Any, frame_xy: Any, frame_pi: Optional[Any] = None) -> BalanceResult:
|
|
43
|
+
"""Voronoi balance of sites ``sample_xy`` against a finite frame.
|
|
44
|
+
|
|
45
|
+
``frame_pi`` are the frame units' inclusion probabilities; by default they
|
|
46
|
+
are equal and sum to the sample size.
|
|
47
|
+
"""
|
|
48
|
+
s = np.asarray(sample_xy, dtype=float).reshape(-1, 2)
|
|
49
|
+
f = np.asarray(frame_xy, dtype=float).reshape(-1, 2)
|
|
50
|
+
n = len(s)
|
|
51
|
+
if n == 0:
|
|
52
|
+
raise ValueError("empty sample")
|
|
53
|
+
if frame_pi is None:
|
|
54
|
+
pi = np.full(len(f), n / len(f))
|
|
55
|
+
else:
|
|
56
|
+
pi = np.asarray(frame_pi, dtype=float).ravel()
|
|
57
|
+
if len(pi) != len(f):
|
|
58
|
+
raise ValueError("frame_pi must have one value per frame point")
|
|
59
|
+
pi = pi * (n / pi.sum())
|
|
60
|
+
owner = nearest_index(f, s)
|
|
61
|
+
v = np.bincount(owner, weights=pi, minlength=n)
|
|
62
|
+
return BalanceResult(value=float(np.mean((v - 1.0) ** 2)), v=v, n=n)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def spatial_balance(
|
|
66
|
+
sample: Any,
|
|
67
|
+
domain: Any = None,
|
|
68
|
+
frame_xy: Any = None,
|
|
69
|
+
frame_pi: Any = None,
|
|
70
|
+
frame_size: int = 20000,
|
|
71
|
+
base_only: bool = True,
|
|
72
|
+
seed: SeedLike = 0,
|
|
73
|
+
) -> BalanceResult:
|
|
74
|
+
"""Voronoi balance of a :class:`Sample` (or ``(n, 2)`` array).
|
|
75
|
+
|
|
76
|
+
Give either a finite frame (``frame_xy``, optional ``frame_pi``) or a
|
|
77
|
+
``domain``, which is discretised into about ``frame_size`` grid points
|
|
78
|
+
with equal inclusion probability.
|
|
79
|
+
"""
|
|
80
|
+
if isinstance(sample, Sample):
|
|
81
|
+
smp = sample.base() if base_only else sample
|
|
82
|
+
xy = smp.xy
|
|
83
|
+
else:
|
|
84
|
+
xy = np.asarray(sample, dtype=float).reshape(-1, 2)
|
|
85
|
+
if frame_xy is None:
|
|
86
|
+
if domain is None:
|
|
87
|
+
raise ValueError("give a domain or frame_xy")
|
|
88
|
+
frame_xy, _ = grid_frame(as_domain(domain), frame_size, as_rng(seed))
|
|
89
|
+
return voronoi_balance(xy, frame_xy, frame_pi)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def compare_designs(
|
|
93
|
+
domain: Any,
|
|
94
|
+
n: int,
|
|
95
|
+
reps: int = 20,
|
|
96
|
+
designs: Optional[Mapping[str, Any]] = None,
|
|
97
|
+
frame_size: int = 20000,
|
|
98
|
+
seed: SeedLike = 0,
|
|
99
|
+
) -> Dict[str, Dict[str, float]]:
|
|
100
|
+
"""Mean and standard deviation of Voronoi balance over repeated draws.
|
|
101
|
+
|
|
102
|
+
``designs`` maps a label to a callable ``f(domain, n, seed) -> Sample``;
|
|
103
|
+
by default SRS, square and hex systematic grids, BAS, GRTS, LPM and
|
|
104
|
+
Poisson-disk are compared.
|
|
105
|
+
"""
|
|
106
|
+
from .bas import bas
|
|
107
|
+
from .designs import poisson_disk, srs, systematic
|
|
108
|
+
from .grts import grts
|
|
109
|
+
from .lpm import lpm
|
|
110
|
+
|
|
111
|
+
dom = as_domain(domain)
|
|
112
|
+
rng = as_rng(seed)
|
|
113
|
+
if designs is None:
|
|
114
|
+
designs = {
|
|
115
|
+
"srs": srs,
|
|
116
|
+
"systematic-square": lambda d, k, seed: systematic(d, k, rotation="random", seed=seed),
|
|
117
|
+
"systematic-hex": lambda d, k, seed: systematic(
|
|
118
|
+
d, k, shape="hex", rotation="random", seed=seed
|
|
119
|
+
),
|
|
120
|
+
"poisson-disk": poisson_disk,
|
|
121
|
+
"bas": bas,
|
|
122
|
+
"grts": grts,
|
|
123
|
+
"lpm": lpm,
|
|
124
|
+
}
|
|
125
|
+
frame, _ = grid_frame(dom, frame_size, rng)
|
|
126
|
+
out: Dict[str, Dict[str, float]] = {}
|
|
127
|
+
for name, fn in designs.items():
|
|
128
|
+
vals = []
|
|
129
|
+
sizes = []
|
|
130
|
+
for _ in range(reps):
|
|
131
|
+
smp = fn(dom, n, seed=rng)
|
|
132
|
+
if isinstance(smp, Sample):
|
|
133
|
+
smp = smp.base()
|
|
134
|
+
xy = smp.xy
|
|
135
|
+
else: # pragma: no cover - custom callables
|
|
136
|
+
xy = np.asarray(smp)
|
|
137
|
+
sizes.append(len(xy))
|
|
138
|
+
vals.append(voronoi_balance(xy, frame).value)
|
|
139
|
+
out[name] = {
|
|
140
|
+
"mean": float(np.mean(vals)),
|
|
141
|
+
"sd": float(np.std(vals, ddof=1)) if reps > 1 else 0.0,
|
|
142
|
+
"mean_n": float(np.mean(sizes)),
|
|
143
|
+
}
|
|
144
|
+
return out
|
spatialsample/bas.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Balanced Acceptance Sampling (BAS), Robertson et al. (2013).
|
|
2
|
+
|
|
3
|
+
BAS draws points from a random-start Halton sequence (bases 2 and 3) over the
|
|
4
|
+
bounding box of the domain and keeps those that fall inside it. Because the
|
|
5
|
+
Halton sequence is a low-discrepancy sequence, consecutive accepted points are
|
|
6
|
+
spread evenly, and the design is an equal-probability design in which each
|
|
7
|
+
location has the same inclusion density ``n / area``. The points after the
|
|
8
|
+
first ``n`` form a natural, still well spread, oversample.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, List
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from ._frame import as_domain
|
|
18
|
+
from ._types import FloatArray, SeedLike, as_rng
|
|
19
|
+
from .sample import Sample
|
|
20
|
+
|
|
21
|
+
__all__ = ["bas", "halton"]
|
|
22
|
+
|
|
23
|
+
_MAX_START = 10_000_000
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def halton(index: Any, base: int) -> FloatArray:
|
|
27
|
+
"""Radical-inverse (van der Corput) values of non-negative integers in ``base``."""
|
|
28
|
+
i = np.asarray(index, dtype=np.int64).copy()
|
|
29
|
+
if np.any(i < 0):
|
|
30
|
+
raise ValueError("Halton indices must be non-negative")
|
|
31
|
+
out = np.zeros(i.shape, dtype=float)
|
|
32
|
+
f = 1.0
|
|
33
|
+
while np.any(i > 0):
|
|
34
|
+
f /= base
|
|
35
|
+
out += f * (i % base)
|
|
36
|
+
i //= base
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def bas(
|
|
41
|
+
domain: Any,
|
|
42
|
+
n: int,
|
|
43
|
+
n_over: int = 0,
|
|
44
|
+
seed: SeedLike = None,
|
|
45
|
+
) -> Sample:
|
|
46
|
+
"""Draw a Balanced Acceptance Sample of ``n`` sites (plus ``n_over`` oversample sites).
|
|
47
|
+
|
|
48
|
+
Parameters
|
|
49
|
+
----------
|
|
50
|
+
domain:
|
|
51
|
+
Polygon :class:`~spatialsample.Region`, :class:`~spatialsample.RasterMask`
|
|
52
|
+
or GeoJSON mapping.
|
|
53
|
+
n, n_over:
|
|
54
|
+
Base and oversample sizes; oversample sites continue the Halton sequence.
|
|
55
|
+
seed:
|
|
56
|
+
Seed for the random start ``(k1, k2)`` of the Halton sequence.
|
|
57
|
+
"""
|
|
58
|
+
rng = as_rng(seed)
|
|
59
|
+
dom = as_domain(domain)
|
|
60
|
+
if n < 1 or n_over < 0:
|
|
61
|
+
raise ValueError("n must be >= 1 and n_over >= 0")
|
|
62
|
+
m = n + n_over
|
|
63
|
+
x0, y0, x1, y1 = dom.bounds
|
|
64
|
+
k1, k2 = (int(v) for v in rng.integers(0, _MAX_START, 2))
|
|
65
|
+
frac = max(dom.area / ((x1 - x0) * (y1 - y0)), 1e-6)
|
|
66
|
+
got: List[FloatArray] = []
|
|
67
|
+
seq: List[np.ndarray] = []
|
|
68
|
+
have = 0
|
|
69
|
+
start = 0
|
|
70
|
+
for _ in range(100000):
|
|
71
|
+
batch = int(max(64, 1.3 * (m - have) / frac))
|
|
72
|
+
j = np.arange(start, start + batch, dtype=np.int64)
|
|
73
|
+
start += batch
|
|
74
|
+
pts = np.column_stack(
|
|
75
|
+
[x0 + (x1 - x0) * halton(k1 + j, 2), y0 + (y1 - y0) * halton(k2 + j, 3)]
|
|
76
|
+
)
|
|
77
|
+
ok = dom.contains(pts)
|
|
78
|
+
got.append(pts[ok])
|
|
79
|
+
seq.append(j[ok])
|
|
80
|
+
have += int(ok.sum())
|
|
81
|
+
if have >= m:
|
|
82
|
+
break
|
|
83
|
+
xy = np.vstack(got)[:m]
|
|
84
|
+
index = np.concatenate(seq)[:m]
|
|
85
|
+
density = n / dom.area
|
|
86
|
+
return Sample(
|
|
87
|
+
xy=xy,
|
|
88
|
+
pi=np.full(m, density),
|
|
89
|
+
weight=np.full(m, 1.0 / density),
|
|
90
|
+
design="bas",
|
|
91
|
+
kind="continuous",
|
|
92
|
+
site_type=np.array(["base"] * n + ["over"] * n_over, dtype=object),
|
|
93
|
+
order=np.arange(m),
|
|
94
|
+
attrs={"halton_index": index},
|
|
95
|
+
meta={"n": n, "n_over": n_over, "area": dom.area, "halton_start": (k1, k2)},
|
|
96
|
+
)
|