stdpipe 0.4.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.
stdpipe/smoothing.py ADDED
@@ -0,0 +1,488 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Callable, Optional, Tuple, Union
4
+
5
+ import numpy as np
6
+ from dataclasses import dataclass
7
+ from scipy.interpolate import RegularGridInterpolator
8
+ from scipy.ndimage import gaussian_filter
9
+ from scipy.spatial import cKDTree
10
+ from sklearn.neighbors import NearestNeighbors
11
+
12
+
13
+ def _tukey_bisquare(u: np.ndarray) -> np.ndarray:
14
+ """
15
+ Tukey bisquare weights for standardized residuals u = r / (c * s).
16
+ Returns weights in [0,1].
17
+ """
18
+ w = np.zeros_like(u, dtype=float)
19
+ m = np.abs(u) < 1.0
20
+ t = 1.0 - u[m] ** 2
21
+ w[m] = t**2
22
+ return w
23
+
24
+
25
+ def _mad_sigma(x: np.ndarray) -> float:
26
+ """Robust sigma estimate via MAD (consistent for normal)."""
27
+ x = np.asarray(x)
28
+ med = np.nanmedian(x)
29
+ mad = np.nanmedian(np.abs(x - med))
30
+ return 1.4826 * mad + 1e-12
31
+
32
+
33
+ @dataclass
34
+ class ApproxLoessRegressor:
35
+ """
36
+ Approximate LOESS/LOWESS via kNN local linear regression in D dimensions.
37
+
38
+ Supports:
39
+ - adaptive bandwidth (h = dist to k-th neighbor)
40
+ - Gaussian kernel
41
+ - robust IRLS using Tukey bisquare (weights computed once during fit
42
+ via leave-one-out self-prediction at the training points)
43
+
44
+ Samples with non-finite positions, values or weights are dropped during
45
+ fit; queries with non-finite positions predict NaN.
46
+
47
+ Typical use: model smooth trend y = f(x, y, mag) and subtract.
48
+ """
49
+
50
+ k: int = 300
51
+ scales: tuple[float, ...] | None = None # per-dimension scaling for distance metric
52
+ kernel: str = "gaussian" # currently only gaussian
53
+ robust_iters: int = 2
54
+ robust_c: float = 4.685 # Tukey tuning constant
55
+ min_bandwidth: float = 1e-6
56
+ ridge: float = 1e-10 # tiny Tikhonov for numerical stability
57
+ leaf_size: int = 40
58
+ n_jobs: int | None = None
59
+
60
+ def fit(self, X: np.ndarray, y: np.ndarray, sample_weight: np.ndarray | None = None):
61
+ """
62
+ Fit stores training set and builds NN index.
63
+ X: (N, D)
64
+ y: (N,)
65
+ sample_weight: optional base weights (e.g., inverse variance). Robust weights are applied on top.
66
+ """
67
+ X = np.asarray(X, float)
68
+ y = np.asarray(y, float).reshape(-1)
69
+ if X.ndim != 2:
70
+ raise ValueError("X must be (N, D)")
71
+ if y.shape[0] != X.shape[0]:
72
+ raise ValueError("y must have length N")
73
+
74
+ D = X.shape[1]
75
+ if self.scales is None:
76
+ scales = np.ones(D, dtype=float)
77
+ else:
78
+ scales = np.asarray(self.scales, float)
79
+ if len(scales) != D:
80
+ raise ValueError(f"scales must have length D={D}")
81
+
82
+ base_w = (
83
+ np.ones_like(y)
84
+ if sample_weight is None
85
+ else np.asarray(sample_weight, float).reshape(-1)
86
+ )
87
+ if base_w.shape[0] != X.shape[0]:
88
+ raise ValueError("sample_weight must have length N")
89
+ if np.any(base_w < 0):
90
+ raise ValueError("sample_weight must be non-negative")
91
+
92
+ # Drop samples with non-finite positions, values or weights; the NN
93
+ # index cannot handle them, and they carry no usable information
94
+ good = np.all(np.isfinite(X), axis=1) & np.isfinite(y) & np.isfinite(base_w)
95
+ if not np.all(good):
96
+ X, y, base_w = X[good], y[good], base_w[good]
97
+ if X.shape[0] == 0:
98
+ raise ValueError("No finite samples to fit")
99
+
100
+ self.X_ = X
101
+ self.y_ = y
102
+ self.base_w_ = base_w
103
+ self.scales_ = scales
104
+ self.Xs_ = self.X_ / self.scales_ # scaled for distance computations
105
+ self.n_samples_ = self.X_.shape[0]
106
+ self.k_ = min(self.k, self.n_samples_)
107
+
108
+ self.nn_ = NearestNeighbors(
109
+ n_neighbors=self.k_,
110
+ algorithm="auto",
111
+ leaf_size=self.leaf_size,
112
+ n_jobs=self.n_jobs,
113
+ )
114
+ self.nn_.fit(self.Xs_)
115
+
116
+ # Robust IRLS weights from leave-one-out self-prediction at the
117
+ # training points, to downweight artifacts in the training set.
118
+ # They depend on the training data only, so they are computed once
119
+ # here rather than on every predict() call
120
+ robust_w = np.ones_like(self.y_)
121
+ for _ in range(self.robust_iters):
122
+ yfit = self._predict_core(self.X_, self.Xs_, robust_w, exclude_self=True)
123
+ resid = self.y_ - yfit
124
+ # Residual scale from points actually contributing to the fit
125
+ weighted = self.base_w_ > 0
126
+ s = _mad_sigma(resid[weighted] if np.any(weighted) else resid)
127
+ u = resid / (self.robust_c * s)
128
+ robust_w = _tukey_bisquare(u)
129
+ # Keep points whose self-prediction failed instead of treating
130
+ # them as outliers
131
+ robust_w[~np.isfinite(resid)] = 1.0
132
+ self.robust_w_ = robust_w
133
+
134
+ return self
135
+
136
+ def predict(self, Xq: np.ndarray, chunk: int = 4096) -> np.ndarray:
137
+ """
138
+ Predict yhat for query points Xq using local linear LOESS.
139
+
140
+ Queries with non-finite coordinates return NaN. Robust weights for
141
+ the training points were pre-computed during :meth:`fit`.
142
+ """
143
+ Xq = np.asarray(Xq, float)
144
+ if Xq.ndim != 2:
145
+ raise ValueError("Xq must be (M, D)")
146
+ if Xq.shape[1] != self.X_.shape[1]:
147
+ raise ValueError("Xq must have same D as training X")
148
+
149
+ yhat = np.full(Xq.shape[0], np.nan, dtype=float)
150
+
151
+ # Non-finite queries would crash the NN search; predict NaN for them
152
+ good = np.all(np.isfinite(Xq), axis=1)
153
+ if np.any(good):
154
+ Xg = Xq[good]
155
+ yhat[good] = self._predict_core(
156
+ Xg, Xg / self.scales_, self.robust_w_, chunk=chunk
157
+ )
158
+
159
+ return yhat
160
+
161
+ def _predict_core(
162
+ self,
163
+ Xq: np.ndarray,
164
+ Xqs: np.ndarray,
165
+ robust_w_train: np.ndarray,
166
+ chunk: int = 4096,
167
+ exclude_self: bool = False,
168
+ ) -> np.ndarray:
169
+ """
170
+ Core prediction with fixed robust weights for training points.
171
+ Uses local linear regression with weights = kernel(distance/h) * base_w * robust_w.
172
+ If exclude_self is True, the nearest self-neighbor is removed where possible.
173
+ """
174
+ M, D = Xq.shape
175
+ out = np.empty(M, float)
176
+
177
+ # Query neighbors
178
+ for start in range(0, M, chunk):
179
+ end = min(M, start + chunk)
180
+ k_base = self.k_
181
+ k_query = k_base + 1 if exclude_self and self.n_samples_ > k_base else k_base
182
+ dists, idxs = self.nn_.kneighbors(
183
+ Xqs[start:end], n_neighbors=k_query, return_distance=True
184
+ ) # (m, k_query)
185
+ if exclude_self and k_query > k_base:
186
+ # Drop one zero-distance (self) neighbour per row where present
187
+ has_self = (dists[:, 0] == 0.0)[:, None]
188
+ dists = np.where(has_self, dists[:, 1:], dists[:, :k_base])
189
+ idxs = np.where(has_self, idxs[:, 1:], idxs[:, :k_base])
190
+ # adaptive bandwidth per query point: h = distance to furthest neighbor
191
+ h = np.maximum(dists[:, -1], self.min_bandwidth) # (m,)
192
+ # kernel weights
193
+ if self.kernel != "gaussian":
194
+ raise NotImplementedError("Only gaussian kernel is implemented")
195
+
196
+ # Gaussian kernel with adaptive bandwidth:
197
+ # w_kernel = exp(-0.5*(d/h)^2)
198
+ z = dists / h[:, None]
199
+ w_kernel = np.exp(-0.5 * z * z)
200
+ if exclude_self and k_query == k_base:
201
+ # k covers the whole training set; zero out one zero-distance
202
+ # (self) neighbour per row, consistent with the branch above
203
+ w_kernel[dists[:, 0] == 0.0, 0] = 0.0
204
+
205
+ # Pull neighbor values
206
+ Yn = self.y_[idxs] # (m, k)
207
+ # Combine weights: base * robust * kernel
208
+ w = w_kernel * (self.base_w_[idxs] * robust_w_train[idxs]) # (m, k)
209
+
210
+ # Where base/robust weights rejected every neighbour, fall back
211
+ # to plain kernel weights instead of silently predicting 0 from
212
+ # the ridge-only system; if even those are all zero, predict NaN
213
+ rejected = ~(np.sum(w, axis=1) > 0)
214
+ if np.any(rejected):
215
+ w[rejected] = w_kernel[rejected]
216
+ unusable = ~(np.sum(w, axis=1) > 0)
217
+
218
+ # Local linear regression around each query:
219
+ # y ≈ b0 + b1*(x-xq) + b2*(y-yq) + b3*(mag-magq) ...
220
+ # Design matrix per query: A = [1, dX] with shape (k, 1+D)
221
+ Xn = self.X_[idxs] # (m, k, D)
222
+ dX = Xn - Xq[start:end, None, :] # (m, k, D)
223
+
224
+ # Build weighted normal equations per query:
225
+ # Beta = (A^T W A + ridge*I)^(-1) (A^T W y)
226
+ # where A = [1, dX]
227
+ m = end - start
228
+ P = 1 + D
229
+ # A: (m, k, P)
230
+ A = np.empty((m, k_base, P), float)
231
+ A[:, :, 0] = 1.0
232
+ A[:, :, 1:] = dX
233
+
234
+ # Compute ATA and ATy with vectorized einsum
235
+ # Apply weights by multiplying rows of A and y by sqrt(w)
236
+ sw = np.sqrt(np.maximum(w, 0.0))
237
+ Aw = A * sw[:, :, None] # (m, k, P)
238
+ yw = Yn * sw # (m, k)
239
+
240
+ ATA = np.einsum("mkp,mkq->mpq", Aw, Aw) # (m, P, P)
241
+ ATy = np.einsum("mkp,mk->mp", Aw, yw) # (m, P)
242
+
243
+ # Ridge for numerical stability (especially in sparse regions);
244
+ # ATA + ridge*I is symmetric positive definite, so the batched
245
+ # solve cannot encounter an exactly singular system
246
+ ATA[:, range(P), range(P)] += self.ridge
247
+
248
+ # Batched solve of the per-query small linear systems.
249
+ # We need b0 only (intercept) because dX=0 at query point.
250
+ b0 = np.linalg.solve(ATA, ATy[:, :, None])[:, 0, 0]
251
+ b0[unusable] = np.nan
252
+ out[start:end] = b0
253
+
254
+ return out
255
+
256
+
257
+ def _fit_loess_field_2d(x, y, dx, dy, scales, k, **kwargs):
258
+ pos = np.column_stack([np.asarray(x, float), np.asarray(y, float)])
259
+ model_dx = ApproxLoessRegressor(k=k, scales=scales, **kwargs)
260
+ model_dx.fit(pos, np.asarray(dx, float))
261
+ if dy is None:
262
+ def predict(xq, yq):
263
+ q = np.column_stack([np.asarray(xq, float), np.asarray(yq, float)])
264
+ return model_dx.predict(q)
265
+ return predict
266
+ model_dy = ApproxLoessRegressor(k=k, scales=scales, **kwargs)
267
+ model_dy.fit(pos, np.asarray(dy, float))
268
+ def predict(xq, yq):
269
+ q = np.column_stack([np.asarray(xq, float), np.asarray(yq, float)])
270
+ return model_dx.predict(q), model_dy.predict(q)
271
+ return predict
272
+
273
+
274
+ def _fill_grid_nearest(values, valid, x_centers, y_centers):
275
+ filled = np.array(values, copy=True)
276
+ if np.all(valid):
277
+ return filled
278
+ yy, xx = np.meshgrid(y_centers, x_centers, indexing="ij")
279
+ pts_valid = np.c_[xx[valid], yy[valid]]
280
+ pts_missing = np.c_[xx[~valid], yy[~valid]]
281
+ tree = cKDTree(pts_valid)
282
+ _, idx = tree.query(pts_missing, k=1)
283
+ filled[~valid] = values[valid][idx]
284
+ return filled
285
+
286
+
287
+ def _smooth_grid(values, weights, sigma):
288
+ if sigma <= 0:
289
+ return values
290
+ num = gaussian_filter(values * weights, sigma=sigma, mode="nearest")
291
+ den = gaussian_filter(weights, sigma=sigma, mode="nearest")
292
+ out = np.array(values, copy=True)
293
+ good = den > 0
294
+ out[good] = num[good] / den[good]
295
+ return out
296
+
297
+
298
+ def _fit_grid_one(x, y, vals, x_edges, y_edges, min_per_cell, smooth_sigma):
299
+ x = np.asarray(x, float)
300
+ y = np.asarray(y, float)
301
+ vals = np.asarray(vals, float)
302
+
303
+ # Non-finite positions would be silently binned into the last cell by
304
+ # digitize+clip, and non-finite values would poison cell medians
305
+ good = np.isfinite(x) & np.isfinite(y) & np.isfinite(vals)
306
+ if not np.all(good):
307
+ x, y, vals = x[good], y[good], vals[good]
308
+
309
+ nx = len(x_edges) - 1
310
+ ny = len(y_edges) - 1
311
+ x_centers = 0.5 * (x_edges[:-1] + x_edges[1:])
312
+ y_centers = 0.5 * (y_edges[:-1] + y_edges[1:])
313
+ ix = np.clip(np.digitize(x, x_edges) - 1, 0, nx - 1)
314
+ iy = np.clip(np.digitize(y, y_edges) - 1, 0, ny - 1)
315
+
316
+ grid = np.full((ny, nx), np.nan, dtype=float)
317
+ counts = np.zeros((ny, nx), dtype=float)
318
+ threshold = max(1, int(min_per_cell))
319
+ valid = None
320
+ while True:
321
+ grid.fill(np.nan)
322
+ counts.fill(0.0)
323
+ # Group sample indices by 1-D bin id, then per-bin nanmedian
324
+ bin_id = iy * nx + ix
325
+ order = np.argsort(bin_id, kind="stable")
326
+ bin_sorted = bin_id[order]
327
+ vals_sorted = vals[order]
328
+ starts = np.searchsorted(bin_sorted, np.arange(ny * nx))
329
+ ends = np.searchsorted(bin_sorted, np.arange(ny * nx) + 1)
330
+ for b, (s, e) in enumerate(zip(starts, ends)):
331
+ if e - s >= threshold:
332
+ jy, jx = divmod(b, nx)
333
+ grid[jy, jx] = np.nanmedian(vals_sorted[s:e])
334
+ counts[jy, jx] = e - s
335
+ valid = np.isfinite(grid) & (counts > 0)
336
+ if np.any(valid) or threshold == 1:
337
+ break
338
+ threshold = max(1, threshold // 2)
339
+
340
+ if not np.any(valid):
341
+ raise RuntimeError("No grid cells have any samples")
342
+
343
+ grid = _fill_grid_nearest(grid, valid, x_centers, y_centers)
344
+ weights = np.where(valid, counts, 0.0)
345
+ grid = _smooth_grid(grid, weights, smooth_sigma)
346
+ interp = RegularGridInterpolator(
347
+ (y_centers, x_centers), grid,
348
+ bounds_error=False, fill_value=None,
349
+ )
350
+ return interp, grid, counts, threshold
351
+
352
+
353
+ def _fit_grid_field_2d(
354
+ x, y, dx, dy, image_shape, grid_shape, min_per_cell, smooth_sigma,
355
+ ):
356
+ x = np.asarray(x, float); y = np.asarray(y, float)
357
+ dx = np.asarray(dx, float)
358
+ if grid_shape[0] < 2 or grid_shape[1] < 2:
359
+ # RegularGridInterpolator needs at least 2 points per dimension
360
+ raise ValueError("grid_shape dimensions must be at least 2")
361
+ if image_shape is None:
362
+ finite = np.isfinite(x) & np.isfinite(y)
363
+ if not np.any(finite):
364
+ raise ValueError("No finite sample positions")
365
+ H = float(np.max(y[finite])) - float(np.min(y[finite]))
366
+ W = float(np.max(x[finite])) - float(np.min(x[finite]))
367
+ if W <= 0 or H <= 0:
368
+ raise ValueError(
369
+ "Degenerate x/y extent; provide image_shape explicitly"
370
+ )
371
+ x0, y0 = float(np.min(x[finite])), float(np.min(y[finite]))
372
+ x_edges = np.linspace(x0, x0 + W, grid_shape[0] + 1)
373
+ y_edges = np.linspace(y0, y0 + H, grid_shape[1] + 1)
374
+ else:
375
+ x_edges = np.linspace(0.0, image_shape[1], grid_shape[0] + 1)
376
+ y_edges = np.linspace(0.0, image_shape[0], grid_shape[1] + 1)
377
+
378
+ interp_dx, _, _, _ = _fit_grid_one(
379
+ x, y, dx, x_edges, y_edges, min_per_cell, smooth_sigma,
380
+ )
381
+ if dy is None:
382
+ def predict(xq, yq):
383
+ pts = np.c_[np.asarray(yq, float), np.asarray(xq, float)]
384
+ return np.asarray(interp_dx(pts), float)
385
+ return predict
386
+ interp_dy, _, _, _ = _fit_grid_one(
387
+ x, y, np.asarray(dy, float), x_edges, y_edges, min_per_cell, smooth_sigma,
388
+ )
389
+ def predict(xq, yq):
390
+ pts = np.c_[np.asarray(yq, float), np.asarray(xq, float)]
391
+ return (np.asarray(interp_dx(pts), float),
392
+ np.asarray(interp_dy(pts), float))
393
+ return predict
394
+
395
+
396
+ def fit_vector_field_2d(
397
+ x: np.ndarray,
398
+ y: np.ndarray,
399
+ dx: np.ndarray,
400
+ dy: Optional[np.ndarray] = None,
401
+ *,
402
+ backend: str = "loess",
403
+ scales: Optional[Tuple[float, float]] = None,
404
+ k: int = 200,
405
+ image_shape: Optional[Tuple[int, int]] = None,
406
+ grid_shape: Tuple[int, int] = (12, 8),
407
+ min_per_cell: int = 6,
408
+ smooth_sigma: float = 1.0,
409
+ **kwargs,
410
+ ) -> Callable[[np.ndarray, np.ndarray],
411
+ Union[np.ndarray, Tuple[np.ndarray, np.ndarray]]]:
412
+ """Fit a smooth scalar or vector 2-D field to scattered samples.
413
+
414
+ Reconstructs a 2-D field (e.g. astrometric (dx, dy) residuals) from
415
+ per-source positions and per-source measurements and returns a callable
416
+ that evaluates the smoothed field at arbitrary positions.
417
+
418
+ Two backends are available with the same return interface:
419
+
420
+ * ``backend="loess"`` (default) wraps :class:`ApproxLoessRegressor`.
421
+ High-quality local-linear smoothing with adaptive bandwidth and
422
+ optional robust IRLS. Best when prediction is needed at modest
423
+ numbers of points (a few times the fit size).
424
+ * ``backend="grid"`` bins the samples on a regular ``grid_shape`` grid,
425
+ takes per-cell medians, fills empty cells from the nearest filled
426
+ cell, optionally Gaussian-smooths in cell units, and returns a
427
+ bilinear interpolator. ~600–1000× faster at prediction than LOESS,
428
+ at the cost of cell-scale resolution and blockier output.
429
+
430
+ Parameters
431
+ ----------
432
+ x, y : array-like, shape (N,)
433
+ Sample positions.
434
+ dx : array-like, shape (N,)
435
+ Sample values, or first component of a vector field if ``dy`` is
436
+ also provided.
437
+ dy : array-like, shape (N,), optional
438
+ Second component of a vector field. When given, the returned
439
+ ``predict`` callable evaluates both components at once.
440
+ backend : {"loess", "grid"}
441
+ Smoothing backend.
442
+ scales : (sx, sy) tuple, optional
443
+ LOESS only. Per-axis distance scaling forwarded to
444
+ :class:`ApproxLoessRegressor`. Defaults to ``(1.0, 1.0)``.
445
+ k : int
446
+ LOESS only. Neighbour count for each local linear fit.
447
+ image_shape : (H, W) tuple, optional
448
+ Grid only. Image shape used to lay out the grid edges. If omitted,
449
+ the bounding box of the input ``(x, y)`` is used.
450
+ grid_shape : (nx, ny) tuple
451
+ Grid only. Number of cells in x and y.
452
+ min_per_cell : int
453
+ Grid only. Minimum sample count per cell required for a valid
454
+ median; cells below the threshold are filled from the nearest
455
+ valid neighbour. Threshold is halved automatically until at least
456
+ one cell is valid.
457
+ smooth_sigma : float
458
+ Grid only. Gaussian smoothing sigma in cell units applied to the
459
+ gridded medians (count-weighted).
460
+ **kwargs :
461
+ LOESS only. Additional keyword arguments forwarded to
462
+ :class:`ApproxLoessRegressor` (``robust_iters``, ``robust_c``, ...).
463
+
464
+ Returns
465
+ -------
466
+ predict : callable
467
+ ``predict(xq, yq)`` returns a single ``ndarray`` for a scalar
468
+ field, or a ``(dx_pred, dy_pred)`` tuple for a vector field.
469
+
470
+ Notes
471
+ -----
472
+ Samples with non-finite positions or values are ignored by both
473
+ backends, and queries at non-finite positions return NaN.
474
+ """
475
+ if backend == "loess":
476
+ if scales is None:
477
+ scales = (1.0, 1.0)
478
+ return _fit_loess_field_2d(x, y, dx, dy, scales, k, **kwargs)
479
+ elif backend == "grid":
480
+ if kwargs:
481
+ raise TypeError(
482
+ f"unexpected kwargs for grid backend: {sorted(kwargs)}"
483
+ )
484
+ return _fit_grid_field_2d(
485
+ x, y, dx, dy, image_shape, grid_shape, min_per_cell, smooth_sigma,
486
+ )
487
+ else:
488
+ raise ValueError(f"unknown backend {backend!r}; use 'loess' or 'grid'")