edmkit 0.0.13__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 (36) hide show
  1. {edmkit-0.0.13 → edmkit-0.0.15}/PKG-INFO +1 -1
  2. {edmkit-0.0.13 → edmkit-0.0.15}/pyproject.toml +2 -1
  3. {edmkit-0.0.13 → edmkit-0.0.15}/pyproject.toml.orig +2 -1
  4. edmkit-0.0.15/src/edmkit/generate/__init__.py +10 -0
  5. edmkit-0.0.15/src/edmkit/generate/ctrnn.py +25 -0
  6. {edmkit-0.0.13 → edmkit-0.0.15}/src/edmkit/generate/double_pendulum.py +6 -30
  7. edmkit-0.0.15/src/edmkit/generate/duffing.py +31 -0
  8. edmkit-0.0.15/src/edmkit/generate/lorenz.py +27 -0
  9. edmkit-0.0.15/src/edmkit/generate/lorenz96.py +25 -0
  10. edmkit-0.0.15/src/edmkit/generate/rk45.py +85 -0
  11. edmkit-0.0.15/src/edmkit/generate/rossler.py +27 -0
  12. edmkit-0.0.15/src/edmkit/simplex_projection/__init__.py +7 -0
  13. edmkit-0.0.15/src/edmkit/simplex_projection/linear_operator.py +206 -0
  14. {edmkit-0.0.13 → edmkit-0.0.15}/src/edmkit/simplex_projection/simplex_projection.py +18 -10
  15. {edmkit-0.0.13 → edmkit-0.0.15}/src/edmkit/simplex_projection/soft.py +22 -13
  16. edmkit-0.0.15/src/edmkit/simplex_projection/soft_linear_operator.py +203 -0
  17. edmkit-0.0.15/src/edmkit/smap/__init__.py +5 -0
  18. edmkit-0.0.13/src/edmkit/smap.py → edmkit-0.0.15/src/edmkit/smap/coefficients.py +71 -171
  19. edmkit-0.0.15/src/edmkit/smap/linear_operator.py +234 -0
  20. edmkit-0.0.15/src/edmkit/smap/smap.py +193 -0
  21. edmkit-0.0.13/src/edmkit/generate/__init__.py +0 -5
  22. edmkit-0.0.13/src/edmkit/generate/lorenz.py +0 -49
  23. edmkit-0.0.13/src/edmkit/simplex_projection/__init__.py +0 -5
  24. {edmkit-0.0.13 → edmkit-0.0.15}/README.md +0 -0
  25. {edmkit-0.0.13 → edmkit-0.0.15}/src/edmkit/ccm.py +0 -0
  26. {edmkit-0.0.13 → edmkit-0.0.15}/src/edmkit/embedding.py +0 -0
  27. {edmkit-0.0.13 → edmkit-0.0.15}/src/edmkit/generate/mackey_glass.py +0 -0
  28. {edmkit-0.0.13 → edmkit-0.0.15}/src/edmkit/metrics/__init__.py +0 -0
  29. {edmkit-0.0.13 → edmkit-0.0.15}/src/edmkit/metrics/mae.py +0 -0
  30. {edmkit-0.0.13 → edmkit-0.0.15}/src/edmkit/metrics/pearson_correlation.py +0 -0
  31. {edmkit-0.0.13 → edmkit-0.0.15}/src/edmkit/metrics/rmse.py +0 -0
  32. {edmkit-0.0.13 → edmkit-0.0.15}/src/edmkit/simplex_projection/knn.py +0 -0
  33. {edmkit-0.0.13 → edmkit-0.0.15}/src/edmkit/splits.py +0 -0
  34. {edmkit-0.0.13 → edmkit-0.0.15}/src/edmkit/theiler_window.py +0 -0
  35. {edmkit-0.0.13 → edmkit-0.0.15}/src/edmkit/types.py +0 -0
  36. {edmkit-0.0.13 → 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.13
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.13"
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"
@@ -20,6 +20,7 @@ email = "tenma.x0@gmail.com"
20
20
  dev = [
21
21
  "griffe2md>=1.5.0",
22
22
  "hypothesis>=6.151.9",
23
+ "matplotlib>=3.11.2",
23
24
  "pytest>=9.1.1",
24
25
  "ruff>=0.15.20",
25
26
  "ty>=0.0.56",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "edmkit"
3
- version = "0.0.13"
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"
@@ -17,6 +17,7 @@ dependencies = [
17
17
  dev = [
18
18
  "griffe2md>=1.5.0",
19
19
  "hypothesis>=6.151.9",
20
+ "matplotlib>=3.11.2",
20
21
  "pytest>=9.1.1",
21
22
  "ruff>=0.15.20",
22
23
  "ty>=0.0.56",
@@ -0,0 +1,10 @@
1
+ from .ctrnn import ctrnn
2
+ from .double_pendulum import double_pendulum, to_xy
3
+ from .duffing import duffing
4
+ from .lorenz import lorenz
5
+ from .lorenz96 import lorenz96
6
+ from .mackey_glass import mackey_glass
7
+ from .rk45 import rk45
8
+ from .rossler import rossler
9
+
10
+ __all__ = ["ctrnn", "double_pendulum", "duffing", "lorenz", "lorenz96", "mackey_glass", "rk45", "rossler", "to_xy"]
@@ -0,0 +1,25 @@
1
+ import numpy as np
2
+
3
+ __all__ = ["ctrnn"]
4
+
5
+
6
+ def ctrnn(g: float, J: np.ndarray):
7
+ """Vector field of a continuous-time recurrent neural network ``dx/dt = -x + g J tanh(x)``.
8
+
9
+ Parameters
10
+ ----------
11
+ g : float
12
+ Gain (chaos for ``g > 1`` when ``J`` is large and random with variance ``1 / N``).
13
+ J : np.ndarray
14
+ Connectivity of shape ``(N, N)``; ``J[i, j]`` is the weight with which unit ``j`` drives unit ``i``.
15
+
16
+ Returns
17
+ -------
18
+ f : Callable[[float, np.ndarray], np.ndarray]
19
+ Right-hand side ``f(t, x)`` for a state of shape ``(N,)``.
20
+ """
21
+
22
+ def f(t: float, x: np.ndarray):
23
+ return -x + g * J @ np.tanh(x)
24
+
25
+ return f
@@ -3,17 +3,8 @@ import numpy as np
3
3
  __all__ = ["double_pendulum", "to_xy"]
4
4
 
5
5
 
6
- def double_pendulum(
7
- m1: float,
8
- m2: float,
9
- L1: float,
10
- L2: float,
11
- g: float,
12
- X0: np.ndarray,
13
- dt: float,
14
- t_max: int,
15
- ):
16
- """Generate double pendulum dynamics via forward Euler integration.
6
+ def double_pendulum(m1: float, m2: float, L1: float, L2: float, g: float):
7
+ """Vector field of the double pendulum.
17
8
 
18
9
  Parameters
19
10
  ----------
@@ -27,22 +18,14 @@ def double_pendulum(
27
18
  Length of second pendulum.
28
19
  g : float
29
20
  Gravitational acceleration.
30
- X0 : np.ndarray
31
- Initial state ``(theta1, theta2, omega1, omega2)`` of shape ``(4,)``.
32
- dt : float
33
- Integration time step.
34
- t_max : int
35
- Maximum time.
36
21
 
37
22
  Returns
38
23
  -------
39
- t : np.ndarray
40
- Time array.
41
- X : np.ndarray
42
- State trajectory of shape ``(N, 4)``.
24
+ f : Callable[[float, np.ndarray], np.ndarray]
25
+ Right-hand side ``f(t, x)`` for a state ``(theta1, theta2, omega1, omega2)`` of shape ``(4,)``.
43
26
  """
44
27
 
45
- def f(x: np.ndarray):
28
+ def f(t: float, x: np.ndarray):
46
29
  theta1, theta2, omega1, omega2 = x
47
30
  delta = theta1 - theta2
48
31
 
@@ -63,14 +46,7 @@ def double_pendulum(
63
46
 
64
47
  return np.array([dtheta1_dt, dtheta2_dt, domega1_dt, domega2_dt])
65
48
 
66
- t = np.arange(0, t_max, dt)
67
- X = np.zeros((len(t), 4))
68
- X[0] = X0
69
-
70
- for i in range(1, len(t)):
71
- X[i] = X[i - 1] + dt * f(X[i - 1])
72
-
73
- return t, X
49
+ return f
74
50
 
75
51
 
76
52
  def to_xy(L1: float, L2: float, theta1: np.ndarray, theta2: np.ndarray):
@@ -0,0 +1,31 @@
1
+ import numpy as np
2
+
3
+ __all__ = ["duffing"]
4
+
5
+
6
+ def duffing(alpha: float, beta: float, delta: float, gamma: float, omega: float):
7
+ """Vector field of the forced Duffing oscillator.
8
+
9
+ Parameters
10
+ ----------
11
+ alpha : float
12
+ Linear stiffness (typical: -1).
13
+ beta : float
14
+ Cubic stiffness (typical: 1).
15
+ delta : float
16
+ Damping coefficient (typical: 0.3).
17
+ gamma : float
18
+ Forcing amplitude (typical: 0.5 for chaos).
19
+ omega : float
20
+ Forcing angular frequency (typical: 1.2).
21
+
22
+ Returns
23
+ -------
24
+ f : Callable[[float, np.ndarray], np.ndarray]
25
+ Right-hand side ``f(t, x)`` for a state ``(x, v)`` of shape ``(2,)``.
26
+ """
27
+
28
+ def f(t: float, x: np.ndarray):
29
+ return np.array([[0, 1], [-alpha - beta * x[0] ** 2, -delta]]) @ x + np.array([0, gamma * np.cos(omega * t)])
30
+
31
+ return f
@@ -0,0 +1,27 @@
1
+ import numpy as np
2
+
3
+ __all__ = ["lorenz"]
4
+
5
+
6
+ def lorenz(sigma: float, rho: float, beta: float):
7
+ """Vector field of the Lorenz system.
8
+
9
+ Parameters
10
+ ----------
11
+ sigma : float
12
+ Prandtl number (typical: 10).
13
+ rho : float
14
+ Rayleigh number (typical: 28).
15
+ beta : float
16
+ Geometric factor (typical: 8/3).
17
+
18
+ Returns
19
+ -------
20
+ f : Callable[[float, np.ndarray], np.ndarray]
21
+ Right-hand side ``f(t, x)`` for a state ``(x, y, z)`` of shape ``(3,)``.
22
+ """
23
+
24
+ def f(t: float, x: np.ndarray):
25
+ return np.array([[-sigma, sigma, 0], [rho, -1, -x[0]], [0, x[0], -beta]]) @ x
26
+
27
+ return f
@@ -0,0 +1,25 @@
1
+ import numpy as np
2
+
3
+ __all__ = ["lorenz96"]
4
+
5
+
6
+ def lorenz96(F: float):
7
+ """Vector field of the Lorenz 96 system.
8
+
9
+ Parameters
10
+ ----------
11
+ F : float
12
+ Forcing constant (typical: 8 for chaos).
13
+
14
+ Returns
15
+ -------
16
+ f : Callable[[float, np.ndarray], np.ndarray]
17
+ Right-hand side ``f(t, x)`` for a state of shape ``(D,)`` with ``D >= 4``.
18
+ """
19
+
20
+ def f(t: float, x: np.ndarray):
21
+ # p[i + 2] is x[i] on the ring, so this is (x[i + 1] - x[i - 2]) * x[i - 1] - x[i] + F
22
+ p = np.concatenate([x[-2:], x, x[:1]])
23
+ return (p[3:] - p[:-3]) * p[1:-2] - x + F
24
+
25
+ return f
@@ -0,0 +1,85 @@
1
+ from collections.abc import Callable
2
+
3
+ import numpy as np
4
+
5
+ __all__ = ["rk45"]
6
+
7
+ # Dormand-Prince 5(4)
8
+ A = np.array(
9
+ [
10
+ [0, 0, 0, 0, 0, 0],
11
+ [1 / 5, 0, 0, 0, 0, 0],
12
+ [3 / 40, 9 / 40, 0, 0, 0, 0],
13
+ [44 / 45, -56 / 15, 32 / 9, 0, 0, 0],
14
+ [19372 / 6561, -25360 / 2187, 64448 / 6561, -212 / 729, 0, 0],
15
+ [9017 / 3168, -355 / 33, 46732 / 5247, 49 / 176, -5103 / 18656, 0],
16
+ [35 / 384, 0, 500 / 1113, 125 / 192, -2187 / 6784, 11 / 84],
17
+ ]
18
+ )
19
+ C = A.sum(axis=1)
20
+ E = np.array([71 / 57600, 0, -71 / 16695, 71 / 1920, -17253 / 339200, 22 / 525, -1 / 40])
21
+
22
+
23
+ def rk45(
24
+ f: Callable[[float, np.ndarray], np.ndarray],
25
+ X0: np.ndarray,
26
+ dt: float,
27
+ t_max: int,
28
+ rtol: float = 1e-6,
29
+ atol: float = 1e-9,
30
+ ):
31
+ """Integrate ``dx/dt = f(t, x)`` via the adaptive Dormand-Prince RK45 method.
32
+
33
+ The step size adapts between samples, while the output stays on the uniform grid of spacing ``dt``.
34
+
35
+ Parameters
36
+ ----------
37
+ f : Callable[[float, np.ndarray], np.ndarray]
38
+ Right-hand side of the system.
39
+ X0 : np.ndarray
40
+ Initial condition of shape ``(D,)``.
41
+ dt : float
42
+ Sampling time step.
43
+ t_max : int
44
+ Maximum time.
45
+ rtol : float
46
+ Relative tolerance of the local error.
47
+ atol : float
48
+ Absolute tolerance of the local error.
49
+
50
+ Returns
51
+ -------
52
+ t : np.ndarray
53
+ Time array.
54
+ X : np.ndarray
55
+ Trajectory of shape ``(N, D)``.
56
+ """
57
+ t = np.arange(0, t_max, dt)
58
+ X = np.zeros((len(t), len(X0)))
59
+ X[0] = X0
60
+
61
+ K = np.zeros((7, len(X0)))
62
+ h = dt
63
+
64
+ for i in range(1, len(t)):
65
+ s, x = t[i - 1], X[i - 1]
66
+
67
+ while s < t[i]:
68
+ last = h >= t[i] - s
69
+ step = t[i] - s if last else h
70
+
71
+ for j in range(7):
72
+ K[j] = f(s + C[j] * step, x + step * (A[j, :j] @ K[:j]))
73
+
74
+ x_new = x + step * (A[6] @ K[:6])
75
+ error = np.max(np.abs(step * (E @ K)) / (atol + rtol * np.maximum(np.abs(x), np.abs(x_new))))
76
+ accepted = error <= 1
77
+
78
+ if accepted:
79
+ s, x = t[i] if last else s + step, x_new
80
+ if not (accepted and last):
81
+ h = step * min(10, max(0.2, 0.9 * error**-0.2)) if error > 0 else step * 10
82
+
83
+ X[i] = x
84
+
85
+ return t, X
@@ -0,0 +1,27 @@
1
+ import numpy as np
2
+
3
+ __all__ = ["rossler"]
4
+
5
+
6
+ def rossler(a: float, b: float, c: float):
7
+ """Vector field of the Rössler system.
8
+
9
+ Parameters
10
+ ----------
11
+ a : float
12
+ Spiral growth rate (typical: 0.2).
13
+ b : float
14
+ Constant injection into z (typical: 0.2).
15
+ c : float
16
+ Folding threshold (typical: 5.7).
17
+
18
+ Returns
19
+ -------
20
+ f : Callable[[float, np.ndarray], np.ndarray]
21
+ Right-hand side ``f(t, x)`` for a state ``(x, y, z)`` of shape ``(3,)``.
22
+ """
23
+
24
+ def f(t: float, x: np.ndarray):
25
+ return np.array([[0, -1, -1], [1, a, 0], [0, 0, x[0] - c]]) @ x + np.array([0, 0, b])
26
+
27
+ return f
@@ -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: