edmkit 0.0.14__tar.gz → 0.0.15__tar.gz
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.
- {edmkit-0.0.14 → edmkit-0.0.15}/PKG-INFO +1 -1
- {edmkit-0.0.14 → edmkit-0.0.15}/pyproject.toml +1 -1
- {edmkit-0.0.14 → edmkit-0.0.15}/pyproject.toml.orig +1 -1
- edmkit-0.0.15/src/edmkit/simplex_projection/__init__.py +7 -0
- edmkit-0.0.15/src/edmkit/simplex_projection/linear_operator.py +206 -0
- {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/simplex_projection/simplex_projection.py +18 -10
- {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/simplex_projection/soft.py +22 -13
- edmkit-0.0.15/src/edmkit/simplex_projection/soft_linear_operator.py +203 -0
- edmkit-0.0.15/src/edmkit/smap/__init__.py +5 -0
- edmkit-0.0.14/src/edmkit/smap.py → edmkit-0.0.15/src/edmkit/smap/coefficients.py +71 -171
- edmkit-0.0.15/src/edmkit/smap/linear_operator.py +234 -0
- edmkit-0.0.15/src/edmkit/smap/smap.py +193 -0
- edmkit-0.0.14/src/edmkit/simplex_projection/__init__.py +0 -5
- {edmkit-0.0.14 → edmkit-0.0.15}/README.md +0 -0
- {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/ccm.py +0 -0
- {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/embedding.py +0 -0
- {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/generate/__init__.py +0 -0
- {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/generate/ctrnn.py +0 -0
- {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/generate/double_pendulum.py +0 -0
- {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/generate/duffing.py +0 -0
- {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/generate/lorenz.py +0 -0
- {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/generate/lorenz96.py +0 -0
- {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/generate/mackey_glass.py +0 -0
- {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/generate/rk45.py +0 -0
- {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/generate/rossler.py +0 -0
- {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/metrics/__init__.py +0 -0
- {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/metrics/mae.py +0 -0
- {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/metrics/pearson_correlation.py +0 -0
- {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/metrics/rmse.py +0 -0
- {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/simplex_projection/knn.py +0 -0
- {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/splits.py +0 -0
- {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/theiler_window.py +0 -0
- {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/types.py +0 -0
- {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/util.py +0 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from .knn import knn as knn
|
|
2
|
+
from .linear_operator import linear_operator
|
|
3
|
+
from .simplex_projection import simplex_projection
|
|
4
|
+
from .soft import soft_simplex_projection
|
|
5
|
+
from .soft_linear_operator import soft_linear_operator
|
|
6
|
+
|
|
7
|
+
__all__ = ["linear_operator", "simplex_projection", "soft_linear_operator", "soft_simplex_projection"]
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, overload
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from edmkit.simplex_projection.knn import knn
|
|
8
|
+
from edmkit.util import pairwise_distance
|
|
9
|
+
|
|
10
|
+
__all__ = ["linear_operator"]
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from tinygrad import Tensor
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@overload
|
|
17
|
+
def linear_operator(
|
|
18
|
+
X: np.ndarray,
|
|
19
|
+
Q: np.ndarray,
|
|
20
|
+
*,
|
|
21
|
+
k: int | None = None,
|
|
22
|
+
mask: np.ndarray | None = None,
|
|
23
|
+
) -> np.ndarray: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@overload
|
|
27
|
+
def linear_operator(
|
|
28
|
+
X: Tensor,
|
|
29
|
+
Q: Tensor,
|
|
30
|
+
*,
|
|
31
|
+
k: int | None = None,
|
|
32
|
+
mask: Tensor | None = None,
|
|
33
|
+
) -> Tensor: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def linear_operator(
|
|
37
|
+
X,
|
|
38
|
+
Q,
|
|
39
|
+
*,
|
|
40
|
+
k=None,
|
|
41
|
+
mask=None,
|
|
42
|
+
):
|
|
43
|
+
"""
|
|
44
|
+
Build the linear operator `S` of simplex projection, such that ``S @ Y`` equals ``simplex_projection(X, Y, Q)`` for any target `Y`.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
X : np.ndarray or Tensor
|
|
49
|
+
The input data of shape (N,) or (N, E) or (B, N, E)
|
|
50
|
+
Q : np.ndarray or Tensor
|
|
51
|
+
The query points of shape (M,) or (M, E) or (B, M, E) for which to find the nearest neighbors in `X`.
|
|
52
|
+
k : int or None, default None
|
|
53
|
+
The number of nearest neighbors to use. If None, uses E + 1, where E is the dimension of `X`.
|
|
54
|
+
mask : np.ndarray or Tensor or None
|
|
55
|
+
Boolean mask of shape (M, N) or (B, M, N) indicating, for each query in `Q`, which library points to include when finding nearest neighbors.
|
|
56
|
+
|
|
57
|
+
Returns
|
|
58
|
+
-------
|
|
59
|
+
S : np.ndarray or Tensor
|
|
60
|
+
The operator of shape (M, N) or (B, M, N).
|
|
61
|
+
|
|
62
|
+
Raises
|
|
63
|
+
------
|
|
64
|
+
ValueError
|
|
65
|
+
- If `k` is not positive.
|
|
66
|
+
- If the batch size or dimension of `X` and `Q` do not match.
|
|
67
|
+
- If `mask` does not have shape (M, N) or (B, M, N).
|
|
68
|
+
- If fewer than `k` library points are unmasked for some query.
|
|
69
|
+
|
|
70
|
+
Examples
|
|
71
|
+
--------
|
|
72
|
+
```python
|
|
73
|
+
import numpy as np
|
|
74
|
+
|
|
75
|
+
from edmkit.simplex_projection import linear_operator, simplex_projection
|
|
76
|
+
|
|
77
|
+
rng = np.random.default_rng(0)
|
|
78
|
+
X = rng.normal(size=(200, 3))
|
|
79
|
+
Q = rng.normal(size=(50, 3))
|
|
80
|
+
Y = rng.normal(size=(200, 4))
|
|
81
|
+
|
|
82
|
+
assert np.allclose(linear_operator(X, Q) @ Y, simplex_projection(X, Y, Q))
|
|
83
|
+
```
|
|
84
|
+
"""
|
|
85
|
+
if isinstance(X, np.ndarray):
|
|
86
|
+
return _numpy(X, Q, k=k, mask=mask)
|
|
87
|
+
|
|
88
|
+
return _tensor(X, Q, k=k, mask=mask)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _numpy(
|
|
92
|
+
X: np.ndarray,
|
|
93
|
+
Q: np.ndarray,
|
|
94
|
+
*,
|
|
95
|
+
k: int | None = None,
|
|
96
|
+
mask: np.ndarray | None = None,
|
|
97
|
+
) -> np.ndarray:
|
|
98
|
+
# ensure at least 2D
|
|
99
|
+
if X.ndim == 1:
|
|
100
|
+
X = X[:, None]
|
|
101
|
+
if Q.ndim == 1:
|
|
102
|
+
Q = Q[:, None]
|
|
103
|
+
|
|
104
|
+
if not (X.ndim == Q.ndim and X.ndim in (2, 3)):
|
|
105
|
+
raise ValueError(f"X and Q must both be 2D or both be 3D arrays, got X.ndim={X.ndim}, Q.ndim={Q.ndim}")
|
|
106
|
+
if Q.shape[:-2] != X.shape[:-2] or Q.shape[-1] != X.shape[-1]:
|
|
107
|
+
raise ValueError(f"batch size and dimension of X and Q must match, got X.shape={X.shape} and Q.shape={Q.shape}")
|
|
108
|
+
if mask is not None and mask.shape != Q.shape[:-1] + X.shape[-2:-1]:
|
|
109
|
+
raise ValueError(f"mask shape must be {Q.shape[:-1] + X.shape[-2:-1]}, got mask.shape={mask.shape}")
|
|
110
|
+
|
|
111
|
+
return_ndim = X.ndim
|
|
112
|
+
|
|
113
|
+
# treat the unbatched case as a single batch
|
|
114
|
+
if X.ndim == 2:
|
|
115
|
+
X, Q = X[None], Q[None]
|
|
116
|
+
if mask is not None:
|
|
117
|
+
mask = mask[None]
|
|
118
|
+
|
|
119
|
+
B, N, E = X.shape
|
|
120
|
+
M = Q.shape[1]
|
|
121
|
+
k = E + 1 if k is None else k
|
|
122
|
+
if k <= 0:
|
|
123
|
+
raise ValueError(f"k must be positive, got k={k}")
|
|
124
|
+
|
|
125
|
+
distances = np.empty((B, M, k))
|
|
126
|
+
indices = np.empty((B, M, k), dtype=np.intp)
|
|
127
|
+
for b in range(B):
|
|
128
|
+
if mask is None:
|
|
129
|
+
distances[b], indices[b] = knn(X[b], Q[b], k)
|
|
130
|
+
else: # over-fetch, then drop the masked-out candidates
|
|
131
|
+
n_exclude = int((~mask[b]).sum(axis=-1).max())
|
|
132
|
+
if N - n_exclude < k:
|
|
133
|
+
raise ValueError(
|
|
134
|
+
f"Not enough unmasked points in X to find {k} neighbors, got N={N} with up to {n_exclude} points masked out per query"
|
|
135
|
+
)
|
|
136
|
+
_distances, _indices = knn(X[b], Q[b], k + n_exclude)
|
|
137
|
+
_distances = np.where(np.take_along_axis(mask[b], _indices, axis=-1), _distances, np.inf)
|
|
138
|
+
top_k = np.argsort(_distances, axis=-1)[..., :k]
|
|
139
|
+
distances[b] = np.take_along_axis(_distances, top_k, axis=-1)
|
|
140
|
+
indices[b] = np.take_along_axis(_indices, top_k, axis=-1)
|
|
141
|
+
|
|
142
|
+
# clamp to avoid division by zero
|
|
143
|
+
d_min = np.fmax(distances.min(axis=-1, keepdims=True), 1e-6) # (B, M, 1)
|
|
144
|
+
weights = np.exp(-distances / d_min) # (B, M, k)
|
|
145
|
+
weights /= weights.sum(axis=-1, keepdims=True)
|
|
146
|
+
|
|
147
|
+
S = np.zeros((B, M, N))
|
|
148
|
+
np.put_along_axis(S, indices, weights, axis=-1)
|
|
149
|
+
|
|
150
|
+
if return_ndim == 2:
|
|
151
|
+
return S[0] # (M, N)
|
|
152
|
+
return S # (B, M, N)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _tensor(
|
|
156
|
+
X: Tensor,
|
|
157
|
+
Q: Tensor,
|
|
158
|
+
*,
|
|
159
|
+
k: int | None = None,
|
|
160
|
+
mask: Tensor | None = None,
|
|
161
|
+
) -> Tensor:
|
|
162
|
+
# ensure at least 2D
|
|
163
|
+
if X.ndim == 1:
|
|
164
|
+
X = X[:, None]
|
|
165
|
+
if Q.ndim == 1:
|
|
166
|
+
Q = Q[:, None]
|
|
167
|
+
|
|
168
|
+
if not (X.ndim == Q.ndim and X.ndim in (2, 3)):
|
|
169
|
+
raise ValueError(f"X and Q must both be 2D or both be 3D tensors, got X.ndim={X.ndim}, Q.ndim={Q.ndim}")
|
|
170
|
+
if Q.shape[:-2] != X.shape[:-2] or Q.shape[-1] != X.shape[-1]:
|
|
171
|
+
raise ValueError(f"batch size and dimension of X and Q must match, got X.shape={X.shape} and Q.shape={Q.shape}")
|
|
172
|
+
if mask is not None and mask.shape != Q.shape[:-1] + X.shape[-2:-1]:
|
|
173
|
+
raise ValueError(f"mask shape must be {Q.shape[:-1] + X.shape[-2:-1]}, got mask.shape={mask.shape}")
|
|
174
|
+
|
|
175
|
+
return_ndim = X.ndim
|
|
176
|
+
|
|
177
|
+
# treat the unbatched case as a single batch
|
|
178
|
+
if X.ndim == 2:
|
|
179
|
+
X, Q = X[None], Q[None]
|
|
180
|
+
if mask is not None:
|
|
181
|
+
mask = mask[None]
|
|
182
|
+
|
|
183
|
+
B, N, E = (int(X.shape[0]), int(X.shape[1]), int(X.shape[2]))
|
|
184
|
+
M = int(Q.shape[1])
|
|
185
|
+
k = E + 1 if k is None else k
|
|
186
|
+
if k <= 0:
|
|
187
|
+
raise ValueError(f"k must be positive, got k={k}")
|
|
188
|
+
|
|
189
|
+
# clamp to avoid NaN gradient
|
|
190
|
+
D = pairwise_distance(Q, X).clamp(min_=1e-12).sqrt() # (B, M, N)
|
|
191
|
+
|
|
192
|
+
if mask is not None:
|
|
193
|
+
D = mask.where(D, float("inf"))
|
|
194
|
+
|
|
195
|
+
distances, indices = D.topk(k, dim=-1, largest=False, sorted_=True) # (B, M, k)
|
|
196
|
+
|
|
197
|
+
# neighbors are sorted, so the first is the nearest; clamp to avoid division by zero
|
|
198
|
+
d_min = distances[..., :1].clamp(min_=1e-6) # (B, M, 1)
|
|
199
|
+
weights = (-distances / d_min).exp() # (B, M, k)
|
|
200
|
+
weights = weights / weights.sum(axis=-1, keepdim=True)
|
|
201
|
+
|
|
202
|
+
S = weights.zeros(B, M, N, dtype=weights.dtype, device=weights.device).scatter(-1, indices, weights) # (B, M, N)
|
|
203
|
+
|
|
204
|
+
if return_ndim == 2:
|
|
205
|
+
return S[0] # (M, N)
|
|
206
|
+
return S # (B, M, N)
|
|
@@ -63,12 +63,14 @@ def simplex_projection(
|
|
|
63
63
|
-------
|
|
64
64
|
predictions : np.ndarray or Tensor
|
|
65
65
|
The predicted values based on the weighted mean of the nearest neighbors in `Y`.
|
|
66
|
+
Shape (M,) if `Y` is 1D, (M, E') if `Y` is 2D, or (B, M, E') if `Y` is 3D.
|
|
66
67
|
|
|
67
68
|
Raises
|
|
68
69
|
------
|
|
69
70
|
ValueError
|
|
70
71
|
- If `k` is not positive.
|
|
71
72
|
- If the input arrays `X` and `Y` do not have the same number of points.
|
|
73
|
+
- If the batch size or dimension of `X` and `Q` do not match.
|
|
72
74
|
- If `mask` does not have shape (M, N) or (B, M, N).
|
|
73
75
|
- If fewer than `k` library points are unmasked for some query.
|
|
74
76
|
|
|
@@ -121,6 +123,8 @@ def _numpy(
|
|
|
121
123
|
k: int | None = None,
|
|
122
124
|
mask: np.ndarray | None = None,
|
|
123
125
|
):
|
|
126
|
+
return_ndim = Y.ndim
|
|
127
|
+
|
|
124
128
|
# ensure at least 2D
|
|
125
129
|
if X.ndim == 1:
|
|
126
130
|
X = X[:, None]
|
|
@@ -139,8 +143,7 @@ def _numpy(
|
|
|
139
143
|
raise ValueError(f"mask shape must be {Q.shape[:-1] + X.shape[-2:-1]}, got mask.shape={mask.shape}")
|
|
140
144
|
|
|
141
145
|
# treat the unbatched case as a single batch
|
|
142
|
-
|
|
143
|
-
if not batched:
|
|
146
|
+
if X.ndim == 2:
|
|
144
147
|
X, Y, Q = X[None], Y[None], Q[None]
|
|
145
148
|
if mask is not None:
|
|
146
149
|
mask = mask[None]
|
|
@@ -177,9 +180,11 @@ def _numpy(
|
|
|
177
180
|
|
|
178
181
|
predictions = np.matmul(weights[..., None, :], Y_neighbors).squeeze(-2) / weights.sum(axis=-1, keepdims=True) # (B, M, E')
|
|
179
182
|
|
|
180
|
-
if
|
|
181
|
-
return predictions
|
|
182
|
-
|
|
183
|
+
if return_ndim == 1:
|
|
184
|
+
return predictions[0, :, 0] # (M,)
|
|
185
|
+
if return_ndim == 2:
|
|
186
|
+
return predictions[0] # (M, E')
|
|
187
|
+
return predictions # (B, M, E')
|
|
183
188
|
|
|
184
189
|
|
|
185
190
|
def _tensor(
|
|
@@ -194,6 +199,8 @@ def _tensor(
|
|
|
194
199
|
# Loading it only when needed keeps the numpy path free of that scheduler pressure.
|
|
195
200
|
from tinygrad import Tensor, dtypes
|
|
196
201
|
|
|
202
|
+
return_ndim = Y.ndim
|
|
203
|
+
|
|
197
204
|
# ensure at least 2D
|
|
198
205
|
if X.ndim == 1:
|
|
199
206
|
X = X[:, None]
|
|
@@ -212,8 +219,7 @@ def _tensor(
|
|
|
212
219
|
raise ValueError(f"mask shape must be {Q.shape[:-1] + X.shape[-2:-1]}, got mask.shape={mask.shape}")
|
|
213
220
|
|
|
214
221
|
# treat the unbatched case as a single batch
|
|
215
|
-
|
|
216
|
-
if not batched:
|
|
222
|
+
if X.ndim == 2:
|
|
217
223
|
X, Y, Q = X[None], Y[None], Q[None]
|
|
218
224
|
if mask is not None:
|
|
219
225
|
mask = mask[None]
|
|
@@ -242,9 +248,11 @@ def _tensor(
|
|
|
242
248
|
|
|
243
249
|
predictions: Tensor = (weights.unsqueeze(-1) * Y_neighbors).sum(axis=-2) / weights.sum(axis=-1, keepdim=True) # (B, M, E')
|
|
244
250
|
|
|
245
|
-
if
|
|
246
|
-
return predictions
|
|
247
|
-
|
|
251
|
+
if return_ndim == 1:
|
|
252
|
+
return predictions[0, :, 0] # (M,)
|
|
253
|
+
if return_ndim == 2:
|
|
254
|
+
return predictions[0] # (M, E')
|
|
255
|
+
return predictions # (B, M, E')
|
|
248
256
|
|
|
249
257
|
|
|
250
258
|
if TYPE_CHECKING:
|
|
@@ -70,6 +70,7 @@ def soft_simplex_projection(
|
|
|
70
70
|
-------
|
|
71
71
|
predictions : np.ndarray or Tensor
|
|
72
72
|
The predicted values based on the weighted mean of the nearest neighbors in `Y`.
|
|
73
|
+
Shape (M,) if `Y` is 1D, (M, E') if `Y` is 2D, or (B, M, E') if `Y` is 3D.
|
|
73
74
|
|
|
74
75
|
Raises
|
|
75
76
|
------
|
|
@@ -77,8 +78,9 @@ def soft_simplex_projection(
|
|
|
77
78
|
- If `k` is not positive.
|
|
78
79
|
- If `softness` is not positive.
|
|
79
80
|
- If the input arrays `X` and `Y` do not have the same number of points.
|
|
80
|
-
- If
|
|
81
|
+
- If the batch size or dimension of `X` and `Q` do not match.
|
|
81
82
|
- If `mask` does not have shape (M, N) or (B, M, N).
|
|
83
|
+
- If fewer than `k + 1` library points are unmasked for some query.
|
|
82
84
|
|
|
83
85
|
Examples
|
|
84
86
|
--------
|
|
@@ -134,6 +136,8 @@ def _numpy(
|
|
|
134
136
|
if softness <= 0:
|
|
135
137
|
raise ValueError(f"softness must be positive, got softness={softness}")
|
|
136
138
|
|
|
139
|
+
return_ndim = Y.ndim
|
|
140
|
+
|
|
137
141
|
# ensure at least 2D
|
|
138
142
|
if X.ndim == 1:
|
|
139
143
|
X = X[:, None]
|
|
@@ -152,8 +156,7 @@ def _numpy(
|
|
|
152
156
|
raise ValueError(f"mask shape must be {Q.shape[:-1] + X.shape[-2:-1]}, got mask.shape={mask.shape}")
|
|
153
157
|
|
|
154
158
|
# treat the unbatched case as a single batch
|
|
155
|
-
|
|
156
|
-
if not batched:
|
|
159
|
+
if X.ndim == 2:
|
|
157
160
|
X, Y, Q = X[None], Y[None], Q[None]
|
|
158
161
|
if mask is not None:
|
|
159
162
|
mask = mask[None]
|
|
@@ -162,8 +165,9 @@ def _numpy(
|
|
|
162
165
|
k = E + 1 if k is None else k
|
|
163
166
|
if k <= 0:
|
|
164
167
|
raise ValueError(f"k must be positive, got k={k}")
|
|
165
|
-
if
|
|
166
|
-
|
|
168
|
+
n_exclude = 0 if mask is None else int((~mask).sum(axis=-1).max())
|
|
169
|
+
if N - n_exclude < k + 1:
|
|
170
|
+
raise ValueError(f"Not enough unmasked points in X to find {k + 1} neighbors, got N={N} with up to {n_exclude} points masked out per query")
|
|
167
171
|
|
|
168
172
|
# clamp to align with _tensor()
|
|
169
173
|
D = np.sqrt(np.maximum(pairwise_distance_np(Q, X), 1e-12)) # (B, M, N)
|
|
@@ -186,9 +190,11 @@ def _numpy(
|
|
|
186
190
|
|
|
187
191
|
predictions = np.matmul(weights, Y) / weights.sum(axis=-1, keepdims=True) # (B, M, E')
|
|
188
192
|
|
|
189
|
-
if
|
|
190
|
-
return predictions
|
|
191
|
-
|
|
193
|
+
if return_ndim == 1:
|
|
194
|
+
return predictions[0, :, 0] # (M,)
|
|
195
|
+
if return_ndim == 2:
|
|
196
|
+
return predictions[0] # (M, E')
|
|
197
|
+
return predictions # (B, M, E')
|
|
192
198
|
|
|
193
199
|
|
|
194
200
|
def _tensor(
|
|
@@ -203,6 +209,8 @@ def _tensor(
|
|
|
203
209
|
if softness <= 0:
|
|
204
210
|
raise ValueError(f"softness must be positive, got softness={softness}")
|
|
205
211
|
|
|
212
|
+
return_ndim = Y.ndim
|
|
213
|
+
|
|
206
214
|
# ensure at least 2D
|
|
207
215
|
if X.ndim == 1:
|
|
208
216
|
X = X[:, None]
|
|
@@ -221,8 +229,7 @@ def _tensor(
|
|
|
221
229
|
raise ValueError(f"mask shape must be {Q.shape[:-1] + X.shape[-2:-1]}, got mask.shape={mask.shape}")
|
|
222
230
|
|
|
223
231
|
# treat the unbatched case as a single batch
|
|
224
|
-
|
|
225
|
-
if not batched:
|
|
232
|
+
if X.ndim == 2:
|
|
226
233
|
X, Y, Q = X[None], Y[None], Q[None]
|
|
227
234
|
if mask is not None:
|
|
228
235
|
mask = mask[None]
|
|
@@ -257,6 +264,8 @@ def _tensor(
|
|
|
257
264
|
|
|
258
265
|
predictions: Tensor = weights.matmul(Y) / weights.sum(axis=-1, keepdim=True) # (B, M, E')
|
|
259
266
|
|
|
260
|
-
if
|
|
261
|
-
return predictions
|
|
262
|
-
|
|
267
|
+
if return_ndim == 1:
|
|
268
|
+
return predictions[0, :, 0] # (M,)
|
|
269
|
+
if return_ndim == 2:
|
|
270
|
+
return predictions[0] # (M, E')
|
|
271
|
+
return predictions # (B, M, E')
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, overload
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy.special import expit
|
|
7
|
+
|
|
8
|
+
from edmkit.util import pairwise_distance, pairwise_distance_np
|
|
9
|
+
|
|
10
|
+
__all__ = ["soft_linear_operator"]
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from tinygrad import Tensor
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@overload
|
|
17
|
+
def soft_linear_operator(
|
|
18
|
+
X: np.ndarray,
|
|
19
|
+
Q: np.ndarray,
|
|
20
|
+
*,
|
|
21
|
+
k: int | None = None,
|
|
22
|
+
mask: np.ndarray | None = None,
|
|
23
|
+
softness: float = 0.02,
|
|
24
|
+
) -> np.ndarray: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@overload
|
|
28
|
+
def soft_linear_operator(
|
|
29
|
+
X: Tensor,
|
|
30
|
+
Q: Tensor,
|
|
31
|
+
*,
|
|
32
|
+
k: int | None = None,
|
|
33
|
+
mask: Tensor | None = None,
|
|
34
|
+
softness: float = 0.02,
|
|
35
|
+
) -> Tensor: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def soft_linear_operator(
|
|
39
|
+
X,
|
|
40
|
+
Q,
|
|
41
|
+
*,
|
|
42
|
+
k=None,
|
|
43
|
+
mask=None,
|
|
44
|
+
softness=0.02,
|
|
45
|
+
):
|
|
46
|
+
"""
|
|
47
|
+
Build the linear operator `S` of soft simplex projection, such that ``S @ Y`` equals ``soft_simplex_projection(X, Y, Q)`` for any target `Y`.
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
X : np.ndarray or Tensor
|
|
52
|
+
The input data of shape (N,) or (N, E) or (B, N, E)
|
|
53
|
+
Q : np.ndarray or Tensor
|
|
54
|
+
The query points of shape (M,) or (M, E) or (B, M, E) for which to find the nearest neighbors in `X`.
|
|
55
|
+
k : int or None, default None
|
|
56
|
+
The number of nearest neighbors to use. If None, uses E + 1, where E is the dimension of `X`.
|
|
57
|
+
mask : np.ndarray or Tensor or None
|
|
58
|
+
Boolean mask of shape (M, N) or (B, M, N) indicating, for each query in `Q`, which library points to include when finding nearest neighbors.
|
|
59
|
+
softness : float, default 0.02
|
|
60
|
+
Width of the boundary between neighbors and non-neighbors, as a fraction of the neighborhood radius.
|
|
61
|
+
|
|
62
|
+
Returns
|
|
63
|
+
-------
|
|
64
|
+
S : np.ndarray or Tensor
|
|
65
|
+
The operator of shape (M, N) or (B, M, N).
|
|
66
|
+
|
|
67
|
+
Raises
|
|
68
|
+
------
|
|
69
|
+
ValueError
|
|
70
|
+
- If `k` is not positive.
|
|
71
|
+
- If `softness` is not positive.
|
|
72
|
+
- If the batch size or dimension of `X` and `Q` do not match.
|
|
73
|
+
- If `mask` does not have shape (M, N) or (B, M, N).
|
|
74
|
+
- If fewer than `k + 1` library points are unmasked for some query.
|
|
75
|
+
|
|
76
|
+
Examples
|
|
77
|
+
--------
|
|
78
|
+
```python
|
|
79
|
+
import numpy as np
|
|
80
|
+
|
|
81
|
+
from edmkit.simplex_projection import soft_linear_operator, soft_simplex_projection
|
|
82
|
+
|
|
83
|
+
rng = np.random.default_rng(0)
|
|
84
|
+
X = rng.normal(size=(200, 3))
|
|
85
|
+
Q = rng.normal(size=(50, 3))
|
|
86
|
+
Y = rng.normal(size=(200, 4))
|
|
87
|
+
|
|
88
|
+
assert np.allclose(soft_linear_operator(X, Q) @ Y, soft_simplex_projection(X, Y, Q))
|
|
89
|
+
```
|
|
90
|
+
"""
|
|
91
|
+
if isinstance(X, np.ndarray):
|
|
92
|
+
return _numpy(X, Q, k=k, mask=mask, softness=softness)
|
|
93
|
+
|
|
94
|
+
return _tensor(X, Q, k=k, mask=mask, softness=softness)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _numpy(
|
|
98
|
+
X: np.ndarray,
|
|
99
|
+
Q: np.ndarray,
|
|
100
|
+
*,
|
|
101
|
+
k: int | None = None,
|
|
102
|
+
mask: np.ndarray | None = None,
|
|
103
|
+
softness: float = 0.02,
|
|
104
|
+
) -> np.ndarray:
|
|
105
|
+
if softness <= 0:
|
|
106
|
+
raise ValueError(f"softness must be positive, got softness={softness}")
|
|
107
|
+
|
|
108
|
+
# ensure at least 2D
|
|
109
|
+
if X.ndim == 1:
|
|
110
|
+
X = X[:, None]
|
|
111
|
+
if Q.ndim == 1:
|
|
112
|
+
Q = Q[:, None]
|
|
113
|
+
|
|
114
|
+
if not (X.ndim == Q.ndim and X.ndim in (2, 3)):
|
|
115
|
+
raise ValueError(f"X and Q must both be 2D or both be 3D arrays, got X.ndim={X.ndim}, Q.ndim={Q.ndim}")
|
|
116
|
+
if Q.shape[:-2] != X.shape[:-2] or Q.shape[-1] != X.shape[-1]:
|
|
117
|
+
raise ValueError(f"batch size and dimension of X and Q must match, got X.shape={X.shape} and Q.shape={Q.shape}")
|
|
118
|
+
if mask is not None and mask.shape != Q.shape[:-1] + X.shape[-2:-1]:
|
|
119
|
+
raise ValueError(f"mask shape must be {Q.shape[:-1] + X.shape[-2:-1]}, got mask.shape={mask.shape}")
|
|
120
|
+
|
|
121
|
+
N, E = X.shape[-2:]
|
|
122
|
+
k = E + 1 if k is None else k
|
|
123
|
+
if k <= 0:
|
|
124
|
+
raise ValueError(f"k must be positive, got k={k}")
|
|
125
|
+
n_exclude = 0 if mask is None else int((~mask).sum(axis=-1).max())
|
|
126
|
+
if N - n_exclude < k + 1:
|
|
127
|
+
raise ValueError(f"Not enough unmasked points in X to find {k + 1} neighbors, got N={N} with up to {n_exclude} points masked out per query")
|
|
128
|
+
|
|
129
|
+
# clamp to align with _tensor()
|
|
130
|
+
D = np.sqrt(np.maximum(pairwise_distance_np(Q, X), 1e-12)) # (M, N) or (B, M, N)
|
|
131
|
+
|
|
132
|
+
if mask is not None:
|
|
133
|
+
# a finite sentinel instead of inf to avoid NaN
|
|
134
|
+
D = np.where(mask, D, D.max(axis=-1, keepdims=True) * 2 + 1)
|
|
135
|
+
|
|
136
|
+
neighbors = np.sort(np.partition(D, k, axis=-1)[..., : k + 1], axis=-1) # (M, k + 1) or (B, M, k + 1)
|
|
137
|
+
|
|
138
|
+
# clamp to avoid division by zero
|
|
139
|
+
d_min = np.maximum(neighbors[..., :1], 1e-6) # (M, 1) or (B, M, 1)
|
|
140
|
+
# radius is the midpoint between the k-th and (k+1)-th nearest neighbors
|
|
141
|
+
radius = (neighbors[..., k - 1 : k] + neighbors[..., k : k + 1]) / 2 # (M, 1) or (B, M, 1)
|
|
142
|
+
radius = np.maximum(radius, 1e-6)
|
|
143
|
+
|
|
144
|
+
weights = np.exp(-D / d_min) * expit((radius - D) / (softness * radius)) # (M, N) or (B, M, N)
|
|
145
|
+
if mask is not None:
|
|
146
|
+
weights = np.where(mask, weights, 0.0)
|
|
147
|
+
|
|
148
|
+
return weights / weights.sum(axis=-1, keepdims=True)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _tensor(
|
|
152
|
+
X: Tensor,
|
|
153
|
+
Q: Tensor,
|
|
154
|
+
*,
|
|
155
|
+
k: int | None = None,
|
|
156
|
+
mask: Tensor | None = None,
|
|
157
|
+
softness: float = 0.02,
|
|
158
|
+
) -> Tensor:
|
|
159
|
+
if softness <= 0:
|
|
160
|
+
raise ValueError(f"softness must be positive, got softness={softness}")
|
|
161
|
+
|
|
162
|
+
# ensure at least 2D
|
|
163
|
+
if X.ndim == 1:
|
|
164
|
+
X = X[:, None]
|
|
165
|
+
if Q.ndim == 1:
|
|
166
|
+
Q = Q[:, None]
|
|
167
|
+
|
|
168
|
+
if not (X.ndim == Q.ndim and X.ndim in (2, 3)):
|
|
169
|
+
raise ValueError(f"X and Q must both be 2D or both be 3D tensors, got X.ndim={X.ndim}, Q.ndim={Q.ndim}")
|
|
170
|
+
if Q.shape[:-2] != X.shape[:-2] or Q.shape[-1] != X.shape[-1]:
|
|
171
|
+
raise ValueError(f"batch size and dimension of X and Q must match, got X.shape={X.shape} and Q.shape={Q.shape}")
|
|
172
|
+
if mask is not None and mask.shape != Q.shape[:-1] + X.shape[-2:-1]:
|
|
173
|
+
raise ValueError(f"mask shape must be {Q.shape[:-1] + X.shape[-2:-1]}, got mask.shape={mask.shape}")
|
|
174
|
+
|
|
175
|
+
N = int(X.shape[-2])
|
|
176
|
+
E = int(X.shape[-1])
|
|
177
|
+
k = E + 1 if k is None else k
|
|
178
|
+
if k <= 0:
|
|
179
|
+
raise ValueError(f"k must be positive, got k={k}")
|
|
180
|
+
if N < k + 1:
|
|
181
|
+
raise ValueError(f"Not enough points in X to find {k + 1} neighbors, got N={N}")
|
|
182
|
+
|
|
183
|
+
# clamp to avoid NaN gradient
|
|
184
|
+
D = pairwise_distance(Q, X).clamp(min_=1e-12).sqrt() # (M, N) or (B, M, N)
|
|
185
|
+
|
|
186
|
+
if mask is not None:
|
|
187
|
+
# a finite sentinel instead of inf to avoid NaN
|
|
188
|
+
D = mask.where(D, D.max(axis=-1, keepdim=True) * 2 + 1)
|
|
189
|
+
|
|
190
|
+
neighbors = D.topk(k + 1, dim=-1, largest=False, sorted_=True)[0] # (M, k + 1) or (B, M, k + 1)
|
|
191
|
+
|
|
192
|
+
# clamp to avoid division by zero
|
|
193
|
+
d_min = neighbors[..., :1].clamp(min_=1e-6) # (M, 1) or (B, M, 1)
|
|
194
|
+
# radius is the midpoint between the k-th and (k+1)-th nearest neighbors
|
|
195
|
+
radius = (neighbors[..., k - 1 : k] + neighbors[..., k : k + 1]) / 2 # (M, 1) or (B, M, 1)
|
|
196
|
+
radius = radius.clamp(min_=1e-6)
|
|
197
|
+
|
|
198
|
+
gate = ((radius - D) / (softness * radius)).sigmoid() # (M, N) or (B, M, N)
|
|
199
|
+
weights = (-D / d_min).exp() * gate # (M, N) or (B, M, N)
|
|
200
|
+
if mask is not None:
|
|
201
|
+
weights = mask.where(weights, 0)
|
|
202
|
+
|
|
203
|
+
return weights / weights.sum(axis=-1, keepdim=True)
|