ppgrid 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.
- ppgrid/__init__.py +1 -0
- ppgrid/__main__.py +6 -0
- ppgrid/calibrate.py +390 -0
- ppgrid/idwgrid.py +735 -0
- ppgrid/pullpush.py +202 -0
- ppgrid-0.1.0.dist-info/METADATA +225 -0
- ppgrid-0.1.0.dist-info/RECORD +10 -0
- ppgrid-0.1.0.dist-info/WHEEL +4 -0
- ppgrid-0.1.0.dist-info/entry_points.txt +2 -0
- ppgrid-0.1.0.dist-info/licenses/LICENSE +21 -0
ppgrid/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""ppgrid — Pull-push scattered-data interpolation."""
|
ppgrid/__main__.py
ADDED
ppgrid/calibrate.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Per-layer calibration: pick the value transform, and derive the fill cap.
|
|
2
|
+
|
|
3
|
+
Derive the fill cap from the data rather than hardcoding it.
|
|
4
|
+
|
|
5
|
+
1. Which transform makes the field spatially predictable?
|
|
6
|
+
Scored by intraclass correlation (between-cell variance / total variance)
|
|
7
|
+
at coarse scales.
|
|
8
|
+
|
|
9
|
+
2. How far may we interpolate before the result is worthless?
|
|
10
|
+
Answered by *spatially blocked* cross-validation. Random k-fold is
|
|
11
|
+
useless here: with clustered address data it puts 92% of held-out
|
|
12
|
+
points within 500m of a training point and reports ~4x the real skill.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import itertools
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
from .pullpush import bin_points, pad_to_pyramid, pull_push
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------- transforms
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Transform:
|
|
29
|
+
"""Base class for value transforms."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
name: str,
|
|
34
|
+
fwd: Callable[[np.ndarray], np.ndarray],
|
|
35
|
+
inv: Callable[[np.ndarray], np.ndarray],
|
|
36
|
+
valid: Callable[[np.ndarray], bool],
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Initialise a transform with forward, inverse, and validity functions."""
|
|
39
|
+
self.name: str = name
|
|
40
|
+
self._fwd: Callable[[np.ndarray], np.ndarray] = fwd
|
|
41
|
+
self._inv: Callable[[np.ndarray], np.ndarray] = inv
|
|
42
|
+
self.valid: Callable[[np.ndarray], bool] = valid
|
|
43
|
+
|
|
44
|
+
def fit(self, _v: np.ndarray) -> Transform:
|
|
45
|
+
"""No-op for stateless transforms.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
self.
|
|
49
|
+
|
|
50
|
+
"""
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def fwd(self, v: np.ndarray) -> np.ndarray:
|
|
54
|
+
"""Transform values to the interpolation space.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Transformed values.
|
|
58
|
+
|
|
59
|
+
"""
|
|
60
|
+
return self._fwd(v)
|
|
61
|
+
|
|
62
|
+
def inv(self, v: np.ndarray) -> np.ndarray:
|
|
63
|
+
"""Map values back to the original scale.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Inverse-transformed values.
|
|
67
|
+
|
|
68
|
+
"""
|
|
69
|
+
return self._inv(v)
|
|
70
|
+
|
|
71
|
+
def state(self) -> dict[str, Any]:
|
|
72
|
+
"""Serialise transform parameters.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Dict with serialisable transform state.
|
|
76
|
+
|
|
77
|
+
"""
|
|
78
|
+
return {"name": self.name}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class PercentileTransform(Transform):
|
|
82
|
+
"""Map values onto their empirical percentile, 0-100."""
|
|
83
|
+
|
|
84
|
+
NQ = 1001
|
|
85
|
+
|
|
86
|
+
def __init__(self, q: np.ndarray | None = None) -> None:
|
|
87
|
+
"""Initialise with optional pre-fitted quantiles."""
|
|
88
|
+
self.name: str = "percentile"
|
|
89
|
+
self.valid: Callable[[np.ndarray], bool] = lambda _v: True
|
|
90
|
+
self.q: np.ndarray | None = None if q is None else np.asarray(q, np.float64)
|
|
91
|
+
|
|
92
|
+
def fit(self, v: np.ndarray) -> PercentileTransform:
|
|
93
|
+
"""Fit quantiles from data.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
self.
|
|
97
|
+
|
|
98
|
+
"""
|
|
99
|
+
q = np.quantile(v.astype(np.float64), np.linspace(0, 1, self.NQ))
|
|
100
|
+
self.q = np.maximum.accumulate(q)
|
|
101
|
+
eps = np.arange(self.NQ) * np.spacing(np.abs(self.q).max() + 1.0)
|
|
102
|
+
self.q += eps
|
|
103
|
+
return self
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def _p(self) -> np.ndarray:
|
|
107
|
+
return np.linspace(0.0, 100.0, self.NQ)
|
|
108
|
+
|
|
109
|
+
def fwd(self, v: np.ndarray) -> np.ndarray:
|
|
110
|
+
"""Map values to percentiles.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Percentile values in range 0-100.
|
|
114
|
+
|
|
115
|
+
"""
|
|
116
|
+
return np.interp(v, self.q, self._p)
|
|
117
|
+
|
|
118
|
+
def inv(self, p: np.ndarray) -> np.ndarray:
|
|
119
|
+
"""Map percentiles back to values.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Original-scale values.
|
|
123
|
+
|
|
124
|
+
"""
|
|
125
|
+
return np.interp(p, self._p, self.q)
|
|
126
|
+
|
|
127
|
+
def state(self) -> dict[str, Any]:
|
|
128
|
+
"""Return serialisable state with quantiles.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Dict with name and quantiles.
|
|
132
|
+
|
|
133
|
+
"""
|
|
134
|
+
return {"name": "percentile", "quantiles": self.q.tolist()}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def transforms() -> list[Transform]:
|
|
138
|
+
"""Fresh candidate instances. MUST be a factory, not a module-level list.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
List of unfitted transform candidates.
|
|
142
|
+
|
|
143
|
+
"""
|
|
144
|
+
return [
|
|
145
|
+
Transform("identity", lambda v: v, lambda t: t, lambda _v: True),
|
|
146
|
+
Transform("log10", np.log10, lambda t: 10.0**t, lambda v: v.min() > 0),
|
|
147
|
+
Transform("sqrt", np.sqrt, lambda t: t**2, lambda v: v.min() >= 0),
|
|
148
|
+
PercentileTransform(),
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def make_transform(state: dict[str, Any]) -> Transform:
|
|
153
|
+
"""Rebuild a fitted transform from its serialised state.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Reconstructed transform instance.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
ValueError: If the transform name is not recognised.
|
|
160
|
+
|
|
161
|
+
"""
|
|
162
|
+
if state.get("name") == "percentile":
|
|
163
|
+
return PercentileTransform(state.get("quantiles"))
|
|
164
|
+
name = state.get("name")
|
|
165
|
+
for t in transforms():
|
|
166
|
+
if t.name == name:
|
|
167
|
+
return t
|
|
168
|
+
msg = f"Unknown transform: {name!r}"
|
|
169
|
+
raise ValueError(msg)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _cell_key(x: np.ndarray, y: np.ndarray, res: float) -> np.ndarray:
|
|
173
|
+
"""Hash (x, y) coordinates to a unique integer per cell at the given resolution.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Integer array of cell keys.
|
|
177
|
+
|
|
178
|
+
"""
|
|
179
|
+
ix = ((x - x.min()) // res).astype(np.int64)
|
|
180
|
+
iy = ((y - y.min()) // res).astype(np.int64)
|
|
181
|
+
return ix * (int(iy.max()) + 1) + iy
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def icc(values: np.ndarray, x: np.ndarray, y: np.ndarray, res: float) -> float:
|
|
185
|
+
"""Intraclass correlation at cell size `res`.
|
|
186
|
+
|
|
187
|
+
The share of total variance explained by location. ~0 means the value
|
|
188
|
+
is not a spatial field at all.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
ICC value between 0 and 1.
|
|
192
|
+
|
|
193
|
+
"""
|
|
194
|
+
key = _cell_key(x, y, res)
|
|
195
|
+
_, inv = np.unique(key, return_inverse=True)
|
|
196
|
+
inv = inv.ravel()
|
|
197
|
+
n = inv.max() + 1
|
|
198
|
+
csum = np.bincount(inv, weights=values, minlength=n)
|
|
199
|
+
ccnt = np.bincount(inv, minlength=n)
|
|
200
|
+
cmean = csum / np.maximum(ccnt, 1)
|
|
201
|
+
within = np.mean((values - cmean[inv]) ** 2)
|
|
202
|
+
total = values.var()
|
|
203
|
+
return float(1.0 - within / total) if total > 0 else 0.0
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def choose_transform(
|
|
207
|
+
x: np.ndarray,
|
|
208
|
+
y: np.ndarray,
|
|
209
|
+
values: np.ndarray,
|
|
210
|
+
scales: tuple[float, ...] = (5000.0, 25000.0, 100000.0),
|
|
211
|
+
) -> tuple[Transform, list[tuple[Transform, float, dict[float, float]]]]:
|
|
212
|
+
"""Pick the transform maximising mean ICC across coarse scales.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Tuple of best transform and all scored results.
|
|
216
|
+
|
|
217
|
+
"""
|
|
218
|
+
results: list[tuple[Transform, float, dict[float, float]]] = []
|
|
219
|
+
for candidate in transforms():
|
|
220
|
+
if not candidate.valid(values):
|
|
221
|
+
continue
|
|
222
|
+
fitted = candidate.fit(values)
|
|
223
|
+
tv = fitted.fwd(values)
|
|
224
|
+
if not np.all(np.isfinite(tv)):
|
|
225
|
+
continue
|
|
226
|
+
per_scale: dict[float, float] = {float(s): icc(tv, x, y, s) for s in scales}
|
|
227
|
+
results.append((fitted, float(np.mean(list(per_scale.values()))), per_scale))
|
|
228
|
+
results.sort(key=lambda r: -r[1])
|
|
229
|
+
return results[0][0], results
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _fit_predict(
|
|
233
|
+
x: np.ndarray,
|
|
234
|
+
y: np.ndarray,
|
|
235
|
+
tv: np.ndarray,
|
|
236
|
+
train: np.ndarray,
|
|
237
|
+
res: float,
|
|
238
|
+
levels: int,
|
|
239
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
240
|
+
"""Train on the given indices, predict at all (x, y) positions.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Tuple of predicted values and support in km.
|
|
244
|
+
|
|
245
|
+
"""
|
|
246
|
+
x0, y0 = x.min(), y.min()
|
|
247
|
+
ix = ((x - x0) // res).astype(np.int64)
|
|
248
|
+
iy = ((y - y0) // res).astype(np.int64)
|
|
249
|
+
nx = pad_to_pyramid(int(ix.max()) + 1, levels)
|
|
250
|
+
ny = pad_to_pyramid(int(iy.max()) + 1, levels)
|
|
251
|
+
s, c = bin_points(ix[train], iy[train], tv[train], nx, ny)
|
|
252
|
+
val, sup = pull_push(s, c, res, levels)
|
|
253
|
+
return val[ix, iy], sup[ix, iy] / 1000.0
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class CVDetail(dict):
|
|
257
|
+
"""Single CV bin result."""
|
|
258
|
+
|
|
259
|
+
lo_km: float
|
|
260
|
+
hi_km: float
|
|
261
|
+
n: int
|
|
262
|
+
skill: float
|
|
263
|
+
ci_lo: float
|
|
264
|
+
ci_hi: float
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class CVCurve(dict):
|
|
268
|
+
"""Per-block-size CV results."""
|
|
269
|
+
|
|
270
|
+
overall_skill: float
|
|
271
|
+
rows: list[CVDetail]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def blocked_cv_skill(
|
|
275
|
+
x: np.ndarray,
|
|
276
|
+
y: np.ndarray,
|
|
277
|
+
tv: np.ndarray,
|
|
278
|
+
res: float = 1000.0,
|
|
279
|
+
levels: int = 9,
|
|
280
|
+
block_km: float = 100.0,
|
|
281
|
+
n_folds: int = 4,
|
|
282
|
+
seed: int = 0,
|
|
283
|
+
edges: tuple[float, ...] = (0, 2, 4, 8, 16, 32, 64, 128, 256, 1e9),
|
|
284
|
+
n_boot: int = 200,
|
|
285
|
+
min_n: int = 150,
|
|
286
|
+
boot_max_n: int = 200_000,
|
|
287
|
+
) -> tuple[float, list[CVDetail]]:
|
|
288
|
+
"""Hold out whole spatial blocks, predict them, and report skill.
|
|
289
|
+
|
|
290
|
+
Report skill (1 - RMSE/RMSE_baseline) per support-scale bin with a
|
|
291
|
+
bootstrap CI.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
Tuple of overall skill score and per-bin CV details.
|
|
295
|
+
|
|
296
|
+
"""
|
|
297
|
+
rng = np.random.default_rng(seed)
|
|
298
|
+
block_size_m = block_km * 1000.0
|
|
299
|
+
bkey = _cell_key(x, y, block_size_m)
|
|
300
|
+
blocks = np.unique(bkey)
|
|
301
|
+
perm = rng.permutation(len(blocks))
|
|
302
|
+
folds = np.array_split(perm, n_folds)
|
|
303
|
+
|
|
304
|
+
preds = np.full(len(tv), np.nan)
|
|
305
|
+
sups = np.full(len(tv), np.nan)
|
|
306
|
+
for f in folds:
|
|
307
|
+
held = np.isin(bkey, blocks[f])
|
|
308
|
+
if held.sum() == 0 or (~held).sum() == 0:
|
|
309
|
+
continue
|
|
310
|
+
p, s = _fit_predict(x, y, tv, ~held, res, levels)
|
|
311
|
+
preds[held] = p[held]
|
|
312
|
+
sups[held] = s[held]
|
|
313
|
+
|
|
314
|
+
ok = np.isfinite(preds) & np.isfinite(sups)
|
|
315
|
+
pred, act, sup = preds[ok], tv[ok], sups[ok]
|
|
316
|
+
base = float(np.mean(act))
|
|
317
|
+
|
|
318
|
+
rows: list[CVDetail] = []
|
|
319
|
+
for lo, hi in itertools.pairwise(edges):
|
|
320
|
+
m = (sup >= lo) & (sup < hi)
|
|
321
|
+
n = int(m.sum())
|
|
322
|
+
if n < min_n:
|
|
323
|
+
continue
|
|
324
|
+
e_m, e_b = act[m] - pred[m], act[m] - base
|
|
325
|
+
skill = 1 - np.sqrt(np.mean(e_m**2)) / np.sqrt(np.mean(e_b**2))
|
|
326
|
+
nb = min(n, boot_max_n)
|
|
327
|
+
if nb < n:
|
|
328
|
+
sub = rng.choice(n, nb, replace=False)
|
|
329
|
+
e_m, e_b = e_m[sub], e_b[sub]
|
|
330
|
+
bs = np.empty(n_boot, dtype=np.float32)
|
|
331
|
+
for k in range(n_boot):
|
|
332
|
+
s = rng.integers(0, nb, size=nb)
|
|
333
|
+
bs[k] = 1 - np.sqrt(np.mean(e_m[s] ** 2)) / np.sqrt(np.mean(e_b[s] ** 2))
|
|
334
|
+
rows.append(
|
|
335
|
+
{
|
|
336
|
+
"lo_km": float(lo),
|
|
337
|
+
"hi_km": float(hi),
|
|
338
|
+
"n": n,
|
|
339
|
+
"skill": float(skill),
|
|
340
|
+
"ci_lo": float(np.percentile(bs, 5)),
|
|
341
|
+
"ci_hi": float(np.percentile(bs, 95)),
|
|
342
|
+
},
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
overall = 1 - (
|
|
346
|
+
np.sqrt(np.mean((act - pred) ** 2)) / np.sqrt(np.mean((act - base) ** 2))
|
|
347
|
+
)
|
|
348
|
+
return float(overall), rows
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def calibrate_fill_cap(
|
|
352
|
+
x: np.ndarray,
|
|
353
|
+
y: np.ndarray,
|
|
354
|
+
tv: np.ndarray,
|
|
355
|
+
block_km: tuple[float, ...] = (50.0, 100.0, 200.0, 400.0),
|
|
356
|
+
min_skill: float = 0.05,
|
|
357
|
+
default_km: float = 25.0,
|
|
358
|
+
**kw: Any,
|
|
359
|
+
) -> tuple[float, dict[float, CVCurve]]:
|
|
360
|
+
"""Derive the fill cap from blocked CV across several held-out block sizes.
|
|
361
|
+
|
|
362
|
+
RULE 1 (admissibility): a support-scale bin is only admissible for a given
|
|
363
|
+
block size if `hi_km <= block_km / 2`. Otherwise the held-out points at
|
|
364
|
+
that support still had training data closer than the scale being tested.
|
|
365
|
+
|
|
366
|
+
RULE 2 (smallest admissible block wins, not the worst): a bin far below
|
|
367
|
+
its block size is populated only by points near the block boundary.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
Tuple of fill cap in km and per-block-size CV curves.
|
|
371
|
+
|
|
372
|
+
"""
|
|
373
|
+
detail: dict[float, CVCurve] = {}
|
|
374
|
+
curve: dict[float, tuple[float, float, int]] = {}
|
|
375
|
+
for bk in sorted(block_km):
|
|
376
|
+
overall, rows = blocked_cv_skill(x, y, tv, block_km=bk, **kw)
|
|
377
|
+
detail[bk] = {"overall_skill": overall, "rows": rows}
|
|
378
|
+
for r in rows:
|
|
379
|
+
if r["hi_km"] <= bk / 2.0 and r["hi_km"] not in curve:
|
|
380
|
+
curve[r["hi_km"]] = (r["ci_lo"], bk, r["n"])
|
|
381
|
+
|
|
382
|
+
cap: float | None = None
|
|
383
|
+
for hi in sorted(curve):
|
|
384
|
+
if curve[hi][0] > min_skill:
|
|
385
|
+
cap = hi
|
|
386
|
+
else:
|
|
387
|
+
break
|
|
388
|
+
for curve_data in detail.values():
|
|
389
|
+
curve_data["cap_km"] = cap
|
|
390
|
+
return (float(cap) if cap else default_km), detail
|