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.
- {edmkit-0.0.13 → edmkit-0.0.15}/PKG-INFO +1 -1
- {edmkit-0.0.13 → edmkit-0.0.15}/pyproject.toml +2 -1
- {edmkit-0.0.13 → edmkit-0.0.15}/pyproject.toml.orig +2 -1
- edmkit-0.0.15/src/edmkit/generate/__init__.py +10 -0
- edmkit-0.0.15/src/edmkit/generate/ctrnn.py +25 -0
- {edmkit-0.0.13 → edmkit-0.0.15}/src/edmkit/generate/double_pendulum.py +6 -30
- edmkit-0.0.15/src/edmkit/generate/duffing.py +31 -0
- edmkit-0.0.15/src/edmkit/generate/lorenz.py +27 -0
- edmkit-0.0.15/src/edmkit/generate/lorenz96.py +25 -0
- edmkit-0.0.15/src/edmkit/generate/rk45.py +85 -0
- edmkit-0.0.15/src/edmkit/generate/rossler.py +27 -0
- 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.13 → edmkit-0.0.15}/src/edmkit/simplex_projection/simplex_projection.py +18 -10
- {edmkit-0.0.13 → 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.13/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.13/src/edmkit/generate/__init__.py +0 -5
- edmkit-0.0.13/src/edmkit/generate/lorenz.py +0 -49
- edmkit-0.0.13/src/edmkit/simplex_projection/__init__.py +0 -5
- {edmkit-0.0.13 → edmkit-0.0.15}/README.md +0 -0
- {edmkit-0.0.13 → edmkit-0.0.15}/src/edmkit/ccm.py +0 -0
- {edmkit-0.0.13 → edmkit-0.0.15}/src/edmkit/embedding.py +0 -0
- {edmkit-0.0.13 → edmkit-0.0.15}/src/edmkit/generate/mackey_glass.py +0 -0
- {edmkit-0.0.13 → edmkit-0.0.15}/src/edmkit/metrics/__init__.py +0 -0
- {edmkit-0.0.13 → edmkit-0.0.15}/src/edmkit/metrics/mae.py +0 -0
- {edmkit-0.0.13 → edmkit-0.0.15}/src/edmkit/metrics/pearson_correlation.py +0 -0
- {edmkit-0.0.13 → edmkit-0.0.15}/src/edmkit/metrics/rmse.py +0 -0
- {edmkit-0.0.13 → edmkit-0.0.15}/src/edmkit/simplex_projection/knn.py +0 -0
- {edmkit-0.0.13 → edmkit-0.0.15}/src/edmkit/splits.py +0 -0
- {edmkit-0.0.13 → edmkit-0.0.15}/src/edmkit/theiler_window.py +0 -0
- {edmkit-0.0.13 → edmkit-0.0.15}/src/edmkit/types.py +0 -0
- {edmkit-0.0.13 → edmkit-0.0.15}/src/edmkit/util.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "edmkit"
|
|
3
|
-
version = "0.0.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|