geomask 0.2.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.
- geomask/__init__.py +103 -0
- geomask/_spatial.py +178 -0
- geomask/_utils.py +77 -0
- geomask/aae.py +214 -0
- geomask/aggregation.py +183 -0
- geomask/cli.py +236 -0
- geomask/evaluate.py +205 -0
- geomask/io.py +80 -0
- geomask/masking.py +336 -0
- geomask/privacy.py +127 -0
- geomask/projection.py +223 -0
- geomask/py.typed +0 -0
- geomask/synthetic.py +65 -0
- geomask/utility.py +274 -0
- geomask-0.2.0.dist-info/METADATA +325 -0
- geomask-0.2.0.dist-info/RECORD +20 -0
- geomask-0.2.0.dist-info/WHEEL +5 -0
- geomask-0.2.0.dist-info/entry_points.txt +2 -0
- geomask-0.2.0.dist-info/licenses/LICENSE +21 -0
- geomask-0.2.0.dist-info/top_level.txt +1 -0
geomask/__init__.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""geomask - geographic masking of confidential point data.
|
|
2
|
+
|
|
3
|
+
Masks (random perturbation, Gaussian displacement, donut and
|
|
4
|
+
population-adaptive donut masking, grid / hexagon / Voronoi aggregation with
|
|
5
|
+
small-cell suppression, location swapping, adaptive areal elimination) plus
|
|
6
|
+
privacy metrics (spatial k-anonymity, re-identification risk) and utility
|
|
7
|
+
metrics (Ripley's L, kernel-density correlation, Moran's I) to document the
|
|
8
|
+
trade-off between them.
|
|
9
|
+
|
|
10
|
+
All masks work on projected coordinates in metres; see
|
|
11
|
+
:mod:`geomask.projection` for lon/lat helpers. Masking is not anonymisation
|
|
12
|
+
on its own - see the README's ethics section.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
__version__ = "0.2.0"
|
|
18
|
+
|
|
19
|
+
from ._spatial import get_backend, set_backend
|
|
20
|
+
from .aae import AAEResult, adaptive_areal_elimination
|
|
21
|
+
from .aggregation import (
|
|
22
|
+
AggregationResult,
|
|
23
|
+
aggregate_by_label,
|
|
24
|
+
aggregate_to_grid,
|
|
25
|
+
aggregate_to_voronoi,
|
|
26
|
+
)
|
|
27
|
+
from .evaluate import EvaluationResult, compare, evaluate, report
|
|
28
|
+
from .masking import (
|
|
29
|
+
DonutResult,
|
|
30
|
+
donut_mask,
|
|
31
|
+
gaussian_displacement,
|
|
32
|
+
location_swap,
|
|
33
|
+
random_perturbation,
|
|
34
|
+
snap_to_grid,
|
|
35
|
+
snap_to_hex,
|
|
36
|
+
)
|
|
37
|
+
from .privacy import (
|
|
38
|
+
DisplacementStats,
|
|
39
|
+
displacement,
|
|
40
|
+
displacement_stats,
|
|
41
|
+
expected_reidentification_risk,
|
|
42
|
+
nearest_neighbour_reidentification_rate,
|
|
43
|
+
spatial_k_anonymity,
|
|
44
|
+
)
|
|
45
|
+
from .projection import UTM, LocalEquirectangular, make_projection, utm_zone
|
|
46
|
+
from .synthetic import SyntheticCity, synthetic_city
|
|
47
|
+
from .utility import (
|
|
48
|
+
ClusterPreservation,
|
|
49
|
+
RipleyComparison,
|
|
50
|
+
cluster_preservation,
|
|
51
|
+
kde_correlation,
|
|
52
|
+
kernel_density,
|
|
53
|
+
mean_displacement,
|
|
54
|
+
morans_i,
|
|
55
|
+
ripley_k,
|
|
56
|
+
ripley_l,
|
|
57
|
+
ripley_l_difference,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
"AAEResult",
|
|
62
|
+
"AggregationResult",
|
|
63
|
+
"ClusterPreservation",
|
|
64
|
+
"DisplacementStats",
|
|
65
|
+
"DonutResult",
|
|
66
|
+
"EvaluationResult",
|
|
67
|
+
"LocalEquirectangular",
|
|
68
|
+
"RipleyComparison",
|
|
69
|
+
"SyntheticCity",
|
|
70
|
+
"UTM",
|
|
71
|
+
"__version__",
|
|
72
|
+
"adaptive_areal_elimination",
|
|
73
|
+
"aggregate_by_label",
|
|
74
|
+
"aggregate_to_grid",
|
|
75
|
+
"aggregate_to_voronoi",
|
|
76
|
+
"cluster_preservation",
|
|
77
|
+
"compare",
|
|
78
|
+
"displacement",
|
|
79
|
+
"displacement_stats",
|
|
80
|
+
"donut_mask",
|
|
81
|
+
"evaluate",
|
|
82
|
+
"expected_reidentification_risk",
|
|
83
|
+
"gaussian_displacement",
|
|
84
|
+
"get_backend",
|
|
85
|
+
"kde_correlation",
|
|
86
|
+
"kernel_density",
|
|
87
|
+
"location_swap",
|
|
88
|
+
"make_projection",
|
|
89
|
+
"mean_displacement",
|
|
90
|
+
"morans_i",
|
|
91
|
+
"nearest_neighbour_reidentification_rate",
|
|
92
|
+
"random_perturbation",
|
|
93
|
+
"report",
|
|
94
|
+
"ripley_k",
|
|
95
|
+
"ripley_l",
|
|
96
|
+
"ripley_l_difference",
|
|
97
|
+
"set_backend",
|
|
98
|
+
"snap_to_grid",
|
|
99
|
+
"snap_to_hex",
|
|
100
|
+
"spatial_k_anonymity",
|
|
101
|
+
"synthetic_city",
|
|
102
|
+
"utm_zone",
|
|
103
|
+
]
|
geomask/_spatial.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Neighbour queries with an optional SciPy KD-tree and a pure-NumPy fallback.
|
|
2
|
+
|
|
3
|
+
Every function here gives identical results with either backend; SciPy only
|
|
4
|
+
makes them faster on large inputs. The backend can be forced with
|
|
5
|
+
:func:`set_backend` (useful for testing) or the ``GEOMASK_NO_SCIPY``
|
|
6
|
+
environment variable.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
try: # pragma: no cover - exercised implicitly depending on the environment
|
|
17
|
+
from scipy.spatial import cKDTree as _KDTree
|
|
18
|
+
|
|
19
|
+
_HAVE_SCIPY = True
|
|
20
|
+
except Exception: # pragma: no cover
|
|
21
|
+
_KDTree = None
|
|
22
|
+
_HAVE_SCIPY = False
|
|
23
|
+
|
|
24
|
+
_use_scipy = _HAVE_SCIPY and not os.environ.get("GEOMASK_NO_SCIPY")
|
|
25
|
+
|
|
26
|
+
#: Upper bound on the number of pairwise distances held in memory at once by
|
|
27
|
+
#: the NumPy fallback.
|
|
28
|
+
_CHUNK_ELEMENTS = 2_000_000
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def set_backend(name: str) -> str:
|
|
32
|
+
"""Select the neighbour-search backend: ``"scipy"``, ``"numpy"`` or ``"auto"``.
|
|
33
|
+
|
|
34
|
+
Returns the backend actually in use. Asking for ``"scipy"`` when SciPy is
|
|
35
|
+
not installed raises :class:`ImportError`.
|
|
36
|
+
"""
|
|
37
|
+
global _use_scipy
|
|
38
|
+
if name == "auto":
|
|
39
|
+
_use_scipy = _HAVE_SCIPY
|
|
40
|
+
elif name == "scipy":
|
|
41
|
+
if not _HAVE_SCIPY:
|
|
42
|
+
raise ImportError("SciPy is not installed; pip install 'geomask[fast]'")
|
|
43
|
+
_use_scipy = True
|
|
44
|
+
elif name == "numpy":
|
|
45
|
+
_use_scipy = False
|
|
46
|
+
else:
|
|
47
|
+
raise ValueError(f"unknown backend {name!r}; use 'scipy', 'numpy' or 'auto'")
|
|
48
|
+
return get_backend()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_backend() -> str:
|
|
52
|
+
"""Return the name of the active backend (``"scipy"`` or ``"numpy"``)."""
|
|
53
|
+
return "scipy" if _use_scipy else "numpy"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _step(n_ref: int) -> int:
|
|
57
|
+
return max(1, _CHUNK_ELEMENTS // max(1, n_ref))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _dist_block(query: np.ndarray, ref: np.ndarray) -> np.ndarray:
|
|
61
|
+
diff = query[:, None, :] - ref[None, :, :]
|
|
62
|
+
return np.sqrt(np.einsum("ijk,ijk->ij", diff, diff))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _tree(ref: np.ndarray) -> Any:
|
|
66
|
+
assert _KDTree is not None
|
|
67
|
+
return _KDTree(ref)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def count_closer(ref: np.ndarray, centers: np.ndarray, radii: np.ndarray) -> np.ndarray:
|
|
71
|
+
"""Count reference points *strictly* closer than ``radii[i]`` to ``centers[i]``."""
|
|
72
|
+
n = len(centers)
|
|
73
|
+
out = np.zeros(n, dtype=np.int64)
|
|
74
|
+
if n == 0 or len(ref) == 0:
|
|
75
|
+
return out
|
|
76
|
+
radii = np.asarray(radii, dtype=float)
|
|
77
|
+
if _use_scipy:
|
|
78
|
+
# query_ball_point uses a closed ball; shrink by one ulp for a strict test.
|
|
79
|
+
r = np.nextafter(radii, -np.inf)
|
|
80
|
+
r = np.where(radii > 0, r, -1.0)
|
|
81
|
+
pos = r >= 0
|
|
82
|
+
if np.any(pos):
|
|
83
|
+
lens = _tree(ref).query_ball_point(centers[pos], r[pos], return_length=True)
|
|
84
|
+
out[pos] = np.asarray(lens, dtype=np.int64)
|
|
85
|
+
return out
|
|
86
|
+
step = _step(len(ref))
|
|
87
|
+
for s in range(0, n, step):
|
|
88
|
+
d = _dist_block(centers[s : s + step], ref)
|
|
89
|
+
out[s : s + step] = np.sum(d < radii[s : s + step, None], axis=1)
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def kth_nearest_distance(ref: np.ndarray, query: np.ndarray, k: int) -> np.ndarray:
|
|
94
|
+
"""Distance from each query point to its ``k``-th nearest reference point (1-based).
|
|
95
|
+
|
|
96
|
+
Reference points at distance zero (e.g. the query location itself) count.
|
|
97
|
+
Returns ``inf`` where fewer than ``k`` reference points exist.
|
|
98
|
+
"""
|
|
99
|
+
if k < 1:
|
|
100
|
+
raise ValueError("k must be >= 1")
|
|
101
|
+
n = len(query)
|
|
102
|
+
if len(ref) < k:
|
|
103
|
+
return np.full(n, np.inf)
|
|
104
|
+
if _use_scipy:
|
|
105
|
+
d, _ = _tree(ref).query(query, k=[k])
|
|
106
|
+
return np.asarray(d, dtype=float).reshape(n)
|
|
107
|
+
out = np.empty(n)
|
|
108
|
+
step = _step(len(ref))
|
|
109
|
+
for s in range(0, n, step):
|
|
110
|
+
d = _dist_block(query[s : s + step], ref)
|
|
111
|
+
out[s : s + step] = np.partition(d, k - 1, axis=1)[:, k - 1]
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def nearest(ref: np.ndarray, query: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
116
|
+
"""Distance to and index of the nearest reference point for each query point."""
|
|
117
|
+
n = len(query)
|
|
118
|
+
if len(ref) == 0:
|
|
119
|
+
raise ValueError("reference set is empty")
|
|
120
|
+
if _use_scipy:
|
|
121
|
+
d, i = _tree(ref).query(query, k=1)
|
|
122
|
+
return np.asarray(d, dtype=float).reshape(n), np.asarray(i, dtype=np.int64).reshape(n)
|
|
123
|
+
dist = np.empty(n)
|
|
124
|
+
idx = np.empty(n, dtype=np.int64)
|
|
125
|
+
step = _step(len(ref))
|
|
126
|
+
for s in range(0, n, step):
|
|
127
|
+
d = _dist_block(query[s : s + step], ref)
|
|
128
|
+
j = np.argmin(d, axis=1)
|
|
129
|
+
idx[s : s + step] = j
|
|
130
|
+
dist[s : s + step] = d[np.arange(len(j)), j]
|
|
131
|
+
return dist, idx
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def within_annulus(
|
|
135
|
+
ref: np.ndarray, query: np.ndarray, r_min: float, r_max: float
|
|
136
|
+
) -> list[np.ndarray]:
|
|
137
|
+
"""Indices of reference points with ``r_min < distance <= r_max`` for each query point."""
|
|
138
|
+
out: list[np.ndarray] = []
|
|
139
|
+
if len(ref) == 0:
|
|
140
|
+
return [np.empty(0, dtype=np.int64) for _ in range(len(query))]
|
|
141
|
+
if _use_scipy:
|
|
142
|
+
tree = _tree(ref)
|
|
143
|
+
lists = tree.query_ball_point(query, r_max)
|
|
144
|
+
for q, idx in zip(query, lists):
|
|
145
|
+
idx = np.asarray(idx, dtype=np.int64)
|
|
146
|
+
if len(idx):
|
|
147
|
+
d = np.hypot(ref[idx, 0] - q[0], ref[idx, 1] - q[1])
|
|
148
|
+
idx = np.sort(idx[d > r_min])
|
|
149
|
+
out.append(idx)
|
|
150
|
+
return out
|
|
151
|
+
step = _step(len(ref))
|
|
152
|
+
for s in range(0, len(query), step):
|
|
153
|
+
d = _dist_block(query[s : s + step], ref)
|
|
154
|
+
mask = (d > r_min) & (d <= r_max)
|
|
155
|
+
out.extend(np.flatnonzero(row) for row in mask)
|
|
156
|
+
return out
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def pair_counts(points: np.ndarray, radii: np.ndarray) -> np.ndarray:
|
|
160
|
+
"""Number of *ordered* pairs ``(i, j), i != j`` with distance ``<= r`` for each radius."""
|
|
161
|
+
radii = np.asarray(radii, dtype=float)
|
|
162
|
+
n = len(points)
|
|
163
|
+
if n < 2:
|
|
164
|
+
return np.zeros(len(radii), dtype=np.int64)
|
|
165
|
+
if _use_scipy:
|
|
166
|
+
tree = _tree(points)
|
|
167
|
+
c = np.asarray(tree.count_neighbors(tree, radii), dtype=np.int64)
|
|
168
|
+
# count_neighbors includes the n self-pairs at distance 0.
|
|
169
|
+
return c - n
|
|
170
|
+
order = np.argsort(radii)
|
|
171
|
+
counts = np.zeros(len(radii), dtype=np.int64)
|
|
172
|
+
step = _step(n)
|
|
173
|
+
sorted_r = radii[order]
|
|
174
|
+
for s in range(0, n, step):
|
|
175
|
+
d = _dist_block(points[s : s + step], points).ravel()
|
|
176
|
+
d = np.sort(d)
|
|
177
|
+
counts[order] += np.searchsorted(d, sorted_r, side="right")
|
|
178
|
+
return counts - n
|
geomask/_utils.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Small shared helpers: input validation and random-number generators."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Union
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
#: Anything accepted as a seed: ``None`` (fresh entropy), an ``int`` or an existing
|
|
10
|
+
#: :class:`numpy.random.Generator` (consumed in place).
|
|
11
|
+
SeedLike = Union[None, int, np.random.Generator]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def as_points(points: object, name: str = "points", allow_nan: bool = False) -> np.ndarray:
|
|
15
|
+
"""Return ``points`` as a float ``(n, 2)`` array, validating shape and values."""
|
|
16
|
+
arr = np.asarray(points, dtype=float)
|
|
17
|
+
if arr.ndim == 1 and arr.size == 2:
|
|
18
|
+
arr = arr.reshape(1, 2)
|
|
19
|
+
if arr.ndim == 1 and arr.size == 0:
|
|
20
|
+
arr = arr.reshape(0, 2)
|
|
21
|
+
if arr.ndim != 2 or arr.shape[1] != 2:
|
|
22
|
+
raise ValueError(f"{name} must have shape (n, 2); got {arr.shape}")
|
|
23
|
+
if allow_nan:
|
|
24
|
+
if np.any(np.isinf(arr)):
|
|
25
|
+
raise ValueError(f"{name} contains infinite coordinates")
|
|
26
|
+
elif not np.all(np.isfinite(arr)):
|
|
27
|
+
raise ValueError(f"{name} contains NaN or infinite coordinates")
|
|
28
|
+
return arr
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def make_rng(seed: SeedLike) -> np.random.Generator:
|
|
32
|
+
"""Return a :class:`numpy.random.Generator` for ``seed``."""
|
|
33
|
+
if isinstance(seed, np.random.Generator):
|
|
34
|
+
return seed
|
|
35
|
+
return np.random.default_rng(seed)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def check_positive(value: float, name: str, allow_zero: bool = False) -> float:
|
|
39
|
+
"""Validate a finite, positive (or non-negative) scalar."""
|
|
40
|
+
v = float(value)
|
|
41
|
+
if not np.isfinite(v) or v < 0 or (v == 0 and not allow_zero):
|
|
42
|
+
cond = ">= 0" if allow_zero else "> 0"
|
|
43
|
+
raise ValueError(f"{name} must be finite and {cond}; got {value!r}")
|
|
44
|
+
return v
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def check_k(k: int, name: str = "k") -> int:
|
|
48
|
+
"""Validate a positive integer threshold."""
|
|
49
|
+
if isinstance(k, bool) or int(k) != k or k < 1:
|
|
50
|
+
raise ValueError(f"{name} must be an integer >= 1; got {k!r}")
|
|
51
|
+
return int(k)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def random_directions(rng: np.random.Generator, n: int) -> np.ndarray:
|
|
55
|
+
"""``n`` unit vectors with uniformly distributed angle, shape ``(n, 2)``."""
|
|
56
|
+
theta = rng.uniform(0.0, 2.0 * np.pi, n)
|
|
57
|
+
return np.column_stack([np.cos(theta), np.sin(theta)])
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def annulus_radii(
|
|
61
|
+
rng: np.random.Generator,
|
|
62
|
+
r_min: np.ndarray,
|
|
63
|
+
r_max: np.ndarray,
|
|
64
|
+
distribution: str = "area",
|
|
65
|
+
) -> np.ndarray:
|
|
66
|
+
"""Random distances in ``[r_min, r_max]``.
|
|
67
|
+
|
|
68
|
+
``"area"`` makes the displaced point uniform over the annulus area
|
|
69
|
+
(density of the distance proportional to ``r``); ``"distance"`` makes the
|
|
70
|
+
distance itself uniform (the original donut formulation).
|
|
71
|
+
"""
|
|
72
|
+
u = rng.uniform(0.0, 1.0, np.shape(r_min))
|
|
73
|
+
if distribution == "area":
|
|
74
|
+
return np.sqrt(r_min**2 + u * (r_max**2 - r_min**2))
|
|
75
|
+
if distribution == "distance":
|
|
76
|
+
return r_min + u * (r_max - r_min)
|
|
77
|
+
raise ValueError(f"distribution must be 'area' or 'distance'; got {distribution!r}")
|
geomask/aae.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Adaptive areal elimination (AAE) after Kounadi & Leitner (2016).
|
|
2
|
+
|
|
3
|
+
AAE builds areas that each hold at least ``k`` people by merging small base
|
|
4
|
+
units with their neighbours, then moves every point to a random location
|
|
5
|
+
inside the merged area that contains it. Because each release area holds at
|
|
6
|
+
least ``k`` population units, an adversary who knows the area learns only
|
|
7
|
+
that the person is one of at least ``k`` residents (*areal* k-anonymity).
|
|
8
|
+
|
|
9
|
+
This implementation uses a regular square grid as base units, so it needs
|
|
10
|
+
nothing beyond NumPy. Pass address points, building centroids or the centres
|
|
11
|
+
of a population raster (with ``population_weights``) as ``population``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import heapq
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from ._utils import SeedLike, as_points, check_k, check_positive, make_rng
|
|
22
|
+
|
|
23
|
+
__all__ = ["AAEResult", "adaptive_areal_elimination"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class AAEResult:
|
|
28
|
+
"""Detailed output of :func:`adaptive_areal_elimination`.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
points: masked coordinates, shape ``(n, 2)``.
|
|
32
|
+
region_of_point: release-area index of each input point.
|
|
33
|
+
region_population: population of each release area (``>= k`` for every
|
|
34
|
+
area that contains a point).
|
|
35
|
+
region_cells: number of base grid cells merged into each release area.
|
|
36
|
+
cell_region: release-area index of every base grid cell, shape
|
|
37
|
+
``(ny, nx)`` (row 0 is the southernmost row).
|
|
38
|
+
origin: lower-left corner of the base grid.
|
|
39
|
+
cell_size: base grid cell size in metres.
|
|
40
|
+
k: anonymity threshold.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
points: np.ndarray
|
|
44
|
+
region_of_point: np.ndarray
|
|
45
|
+
region_population: np.ndarray
|
|
46
|
+
region_cells: np.ndarray
|
|
47
|
+
cell_region: np.ndarray
|
|
48
|
+
origin: np.ndarray
|
|
49
|
+
cell_size: float
|
|
50
|
+
k: int
|
|
51
|
+
|
|
52
|
+
def region_of(self, xy: object) -> np.ndarray:
|
|
53
|
+
"""Release-area index for arbitrary coordinates (``-1`` outside the grid)."""
|
|
54
|
+
p = as_points(xy, "xy")
|
|
55
|
+
ny, nx = self.cell_region.shape
|
|
56
|
+
ij = np.floor((p - self.origin) / self.cell_size).astype(np.int64)
|
|
57
|
+
inside = (ij[:, 0] >= 0) & (ij[:, 0] < nx) & (ij[:, 1] >= 0) & (ij[:, 1] < ny)
|
|
58
|
+
out = np.full(len(p), -1, dtype=np.int64)
|
|
59
|
+
out[inside] = self.cell_region[ij[inside, 1], ij[inside, 0]]
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def adaptive_areal_elimination(
|
|
64
|
+
points: object,
|
|
65
|
+
population: object,
|
|
66
|
+
k: int,
|
|
67
|
+
cell_size: float,
|
|
68
|
+
seed: SeedLike = None,
|
|
69
|
+
*,
|
|
70
|
+
population_weights: object | None = None,
|
|
71
|
+
placement: str = "uniform",
|
|
72
|
+
return_details: bool = False,
|
|
73
|
+
) -> np.ndarray | AAEResult:
|
|
74
|
+
"""Mask points with adaptive areal elimination (AAE).
|
|
75
|
+
|
|
76
|
+
Algorithm:
|
|
77
|
+
|
|
78
|
+
1. Lay a square grid of ``cell_size`` metres over the points and the
|
|
79
|
+
population and count the population in each cell.
|
|
80
|
+
2. While some area that contains a point has fewer than ``k`` people,
|
|
81
|
+
take the one with the smallest population and merge it with the
|
|
82
|
+
edge-adjacent area of smallest population.
|
|
83
|
+
3. Move every point to a random location in its merged area:
|
|
84
|
+
``placement="uniform"`` picks a uniform location over the area;
|
|
85
|
+
``"population"`` picks a random population unit in the area
|
|
86
|
+
(weighted), which keeps points on plausible residential locations.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
points: ``(n, 2)`` true locations (metres).
|
|
90
|
+
population: ``(m, 2)`` population unit locations.
|
|
91
|
+
k: minimum population per release area.
|
|
92
|
+
cell_size: base grid resolution in metres. Smaller cells give more
|
|
93
|
+
compact, data-adaptive areas at a higher computing cost.
|
|
94
|
+
seed: random seed or generator.
|
|
95
|
+
population_weights: optional people per population unit (default 1).
|
|
96
|
+
placement: ``"uniform"`` or ``"population"``.
|
|
97
|
+
return_details: return an :class:`AAEResult` instead of an array.
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
ValueError: if the total population is below ``k``.
|
|
101
|
+
"""
|
|
102
|
+
pts = as_points(points)
|
|
103
|
+
pop = as_points(population, "population")
|
|
104
|
+
k = check_k(k)
|
|
105
|
+
cs = check_positive(cell_size, "cell_size")
|
|
106
|
+
if placement not in {"uniform", "population"}:
|
|
107
|
+
raise ValueError("placement must be 'uniform' or 'population'")
|
|
108
|
+
if population_weights is None:
|
|
109
|
+
w = np.ones(len(pop))
|
|
110
|
+
else:
|
|
111
|
+
w = np.asarray(population_weights, dtype=float).reshape(-1)
|
|
112
|
+
if w.shape != (len(pop),) or np.any(w < 0) or not np.all(np.isfinite(w)):
|
|
113
|
+
raise ValueError("population_weights must be finite, >= 0, one per unit")
|
|
114
|
+
if w.sum() < k:
|
|
115
|
+
raise ValueError(f"total population {w.sum():g} is below k={k}")
|
|
116
|
+
rng = make_rng(seed)
|
|
117
|
+
|
|
118
|
+
allpts = np.vstack([pts, pop])
|
|
119
|
+
origin = np.floor(allpts.min(axis=0) / cs) * cs
|
|
120
|
+
nx, ny = (np.floor((allpts.max(axis=0) - origin) / cs).astype(np.int64) + 1).tolist()
|
|
121
|
+
ncell = nx * ny
|
|
122
|
+
|
|
123
|
+
def cell_index(p: np.ndarray) -> np.ndarray:
|
|
124
|
+
ij = np.floor((p - origin) / cs).astype(np.int64)
|
|
125
|
+
ij[:, 0] = np.clip(ij[:, 0], 0, nx - 1)
|
|
126
|
+
ij[:, 1] = np.clip(ij[:, 1], 0, ny - 1)
|
|
127
|
+
return np.asarray(ij[:, 1] * nx + ij[:, 0], dtype=np.int64)
|
|
128
|
+
|
|
129
|
+
pt_cell = cell_index(pts)
|
|
130
|
+
pop_cell = cell_index(pop)
|
|
131
|
+
cell_pop = np.bincount(pop_cell, weights=w, minlength=ncell)
|
|
132
|
+
has_pt = np.bincount(pt_cell, minlength=ncell) > 0
|
|
133
|
+
|
|
134
|
+
parent = np.arange(ncell)
|
|
135
|
+
rpop: dict[int, float] = {c: float(cell_pop[c]) for c in range(ncell)}
|
|
136
|
+
rhas: dict[int, bool] = {c: bool(has_pt[c]) for c in range(ncell)}
|
|
137
|
+
nbrs: dict[int, set[int]] = {}
|
|
138
|
+
for c in range(ncell):
|
|
139
|
+
cx, cy = c % nx, c // nx
|
|
140
|
+
s: set[int] = set()
|
|
141
|
+
if cx > 0:
|
|
142
|
+
s.add(c - 1)
|
|
143
|
+
if cx < nx - 1:
|
|
144
|
+
s.add(c + 1)
|
|
145
|
+
if cy > 0:
|
|
146
|
+
s.add(c - nx)
|
|
147
|
+
if cy < ny - 1:
|
|
148
|
+
s.add(c + nx)
|
|
149
|
+
nbrs[c] = s
|
|
150
|
+
|
|
151
|
+
heap = [(rpop[c], c) for c in range(ncell) if rhas[c] and rpop[c] < k]
|
|
152
|
+
heapq.heapify(heap)
|
|
153
|
+
while heap:
|
|
154
|
+
p_a, a = heapq.heappop(heap)
|
|
155
|
+
if a not in rpop or rpop[a] != p_a or rpop[a] >= k:
|
|
156
|
+
continue
|
|
157
|
+
# Total population >= k guarantees a neighbour exists while rpop[a] < k.
|
|
158
|
+
b = min(nbrs[a], key=lambda r: (rpop[r], r))
|
|
159
|
+
# Merge b into a.
|
|
160
|
+
parent[b] = a
|
|
161
|
+
rpop[a] += rpop.pop(b)
|
|
162
|
+
rhas[a] = rhas[a] or rhas.pop(b)
|
|
163
|
+
for nb in nbrs.pop(b):
|
|
164
|
+
nbrs[nb].discard(b)
|
|
165
|
+
if nb != a:
|
|
166
|
+
nbrs[nb].add(a)
|
|
167
|
+
nbrs[a].add(nb)
|
|
168
|
+
nbrs[a].discard(b)
|
|
169
|
+
nbrs[a].discard(a)
|
|
170
|
+
if rpop[a] < k:
|
|
171
|
+
heapq.heappush(heap, (rpop[a], a))
|
|
172
|
+
|
|
173
|
+
# Resolve union-find roots (path compression by repeated lookup).
|
|
174
|
+
root = parent.copy()
|
|
175
|
+
while True:
|
|
176
|
+
nxt = root[root]
|
|
177
|
+
if np.array_equal(nxt, root):
|
|
178
|
+
break
|
|
179
|
+
root = nxt
|
|
180
|
+
roots, cell_region = np.unique(root, return_inverse=True)
|
|
181
|
+
cell_region = cell_region.reshape(-1)
|
|
182
|
+
n_reg = len(roots)
|
|
183
|
+
region_population = np.bincount(cell_region, weights=cell_pop, minlength=n_reg)
|
|
184
|
+
region_cells = np.bincount(cell_region, minlength=n_reg)
|
|
185
|
+
region_of_point = cell_region[pt_cell]
|
|
186
|
+
|
|
187
|
+
out = np.empty_like(pts)
|
|
188
|
+
order = np.argsort(cell_region, kind="stable")
|
|
189
|
+
starts = np.concatenate([[0], np.cumsum(region_cells)])
|
|
190
|
+
pop_region = cell_region[pop_cell]
|
|
191
|
+
for i in range(len(pts)):
|
|
192
|
+
reg = region_of_point[i]
|
|
193
|
+
if placement == "uniform":
|
|
194
|
+
cells = order[starts[reg] : starts[reg + 1]]
|
|
195
|
+
c = cells[rng.integers(len(cells))]
|
|
196
|
+
ll = origin + cs * np.array([c % nx, c // nx], dtype=float)
|
|
197
|
+
out[i] = ll + rng.uniform(0.0, cs, 2)
|
|
198
|
+
else:
|
|
199
|
+
cand = np.flatnonzero((pop_region == reg) & (w > 0))
|
|
200
|
+
pr = w[cand] / w[cand].sum()
|
|
201
|
+
out[i] = pop[cand[rng.choice(len(cand), p=pr)]]
|
|
202
|
+
|
|
203
|
+
if return_details:
|
|
204
|
+
return AAEResult(
|
|
205
|
+
points=out,
|
|
206
|
+
region_of_point=region_of_point,
|
|
207
|
+
region_population=region_population,
|
|
208
|
+
region_cells=region_cells,
|
|
209
|
+
cell_region=cell_region.reshape(ny, nx),
|
|
210
|
+
origin=origin,
|
|
211
|
+
cell_size=cs,
|
|
212
|
+
k=k,
|
|
213
|
+
)
|
|
214
|
+
return out
|