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.
Files changed (34) hide show
  1. {edmkit-0.0.14 → edmkit-0.0.15}/PKG-INFO +1 -1
  2. {edmkit-0.0.14 → edmkit-0.0.15}/pyproject.toml +1 -1
  3. {edmkit-0.0.14 → edmkit-0.0.15}/pyproject.toml.orig +1 -1
  4. edmkit-0.0.15/src/edmkit/simplex_projection/__init__.py +7 -0
  5. edmkit-0.0.15/src/edmkit/simplex_projection/linear_operator.py +206 -0
  6. {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/simplex_projection/simplex_projection.py +18 -10
  7. {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/simplex_projection/soft.py +22 -13
  8. edmkit-0.0.15/src/edmkit/simplex_projection/soft_linear_operator.py +203 -0
  9. edmkit-0.0.15/src/edmkit/smap/__init__.py +5 -0
  10. edmkit-0.0.14/src/edmkit/smap.py → edmkit-0.0.15/src/edmkit/smap/coefficients.py +71 -171
  11. edmkit-0.0.15/src/edmkit/smap/linear_operator.py +234 -0
  12. edmkit-0.0.15/src/edmkit/smap/smap.py +193 -0
  13. edmkit-0.0.14/src/edmkit/simplex_projection/__init__.py +0 -5
  14. {edmkit-0.0.14 → edmkit-0.0.15}/README.md +0 -0
  15. {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/ccm.py +0 -0
  16. {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/embedding.py +0 -0
  17. {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/generate/__init__.py +0 -0
  18. {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/generate/ctrnn.py +0 -0
  19. {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/generate/double_pendulum.py +0 -0
  20. {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/generate/duffing.py +0 -0
  21. {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/generate/lorenz.py +0 -0
  22. {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/generate/lorenz96.py +0 -0
  23. {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/generate/mackey_glass.py +0 -0
  24. {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/generate/rk45.py +0 -0
  25. {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/generate/rossler.py +0 -0
  26. {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/metrics/__init__.py +0 -0
  27. {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/metrics/mae.py +0 -0
  28. {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/metrics/pearson_correlation.py +0 -0
  29. {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/metrics/rmse.py +0 -0
  30. {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/simplex_projection/knn.py +0 -0
  31. {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/splits.py +0 -0
  32. {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/theiler_window.py +0 -0
  33. {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/types.py +0 -0
  34. {edmkit-0.0.14 → edmkit-0.0.15}/src/edmkit/util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: edmkit
3
- Version: 0.0.14
3
+ Version: 0.0.15
4
4
  Summary: Simple EDM (Empirical Dynamic Modeling) library
5
5
  Author: FUJISHIGE TEMMA
6
6
  Author-email: FUJISHIGE TEMMA <tenma.x0@gmail.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "edmkit"
3
- version = "0.0.14"
3
+ version = "0.0.15"
4
4
  description = "Simple EDM (Empirical Dynamic Modeling) library"
5
5
  readme = "README.md"
6
6
  requires-python = ">= 3.13"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "edmkit"
3
- version = "0.0.14"
3
+ version = "0.0.15"
4
4
  description = "Simple EDM (Empirical Dynamic Modeling) library"
5
5
  authors = [{ name = "FUJISHIGE TEMMA", email = "tenma.x0@gmail.com" }]
6
6
  readme = "README.md"
@@ -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
- batched = X.ndim == 3
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 not batched:
181
- return predictions.squeeze() # (M,) or (M, E')
182
- return predictions
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
- batched = X.ndim == 3
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 not batched:
246
- return predictions.squeeze() # (M,) or (M, E')
247
- return predictions
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 `X` does not contain at least `k + 1` points.
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
- batched = X.ndim == 3
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 N < k + 1:
166
- raise ValueError(f"Not enough points in X to find {k + 1} neighbors, got N={N}")
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 not batched:
190
- return predictions.squeeze() # (M,) or (M, E')
191
- return predictions
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
- batched = X.ndim == 3
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 not batched:
261
- return predictions.squeeze() # (M,) or (M, E')
262
- return predictions
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)
@@ -0,0 +1,5 @@
1
+ from .coefficients import coefficients
2
+ from .linear_operator import linear_operator
3
+ from .smap import smap
4
+
5
+ __all__ = ["coefficients", "linear_operator", "smap"]