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 ADDED
@@ -0,0 +1 @@
1
+ """ppgrid — Pull-push scattered-data interpolation."""
ppgrid/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point: python -m ppgrid ..."""
2
+
3
+ from .idwgrid import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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