edmkit 0.0.16__tar.gz → 0.0.18__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 (50) hide show
  1. edmkit-0.0.18/PKG-INFO +87 -0
  2. edmkit-0.0.18/README.md +73 -0
  3. {edmkit-0.0.16 → edmkit-0.0.18}/pyproject.toml +21 -9
  4. {edmkit-0.0.16 → edmkit-0.0.18}/pyproject.toml.orig +18 -9
  5. edmkit-0.0.18/src/edmkit/ccm.py +168 -0
  6. edmkit-0.0.18/src/edmkit/simplex_projection/knn.py +65 -0
  7. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/simplex_projection/linear_operator.py +20 -31
  8. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/simplex_projection/simplex_projection.py +28 -44
  9. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/simplex_projection/soft.py +27 -26
  10. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/simplex_projection/soft_linear_operator.py +23 -22
  11. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/smap/coefficients.py +35 -27
  12. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/smap/linear_operator.py +36 -27
  13. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/smap/smap.py +8 -7
  14. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/theiler_window.py +15 -8
  15. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/types.py +10 -3
  16. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/util.py +69 -2
  17. edmkit-0.0.16/PKG-INFO +0 -48
  18. edmkit-0.0.16/README.md +0 -34
  19. edmkit-0.0.16/src/edmkit/ccm.py +0 -274
  20. edmkit-0.0.16/src/edmkit/simplex_projection/knn.py +0 -33
  21. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/embedding.py +0 -0
  22. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/__init__.py +0 -0
  23. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/chua.py +0 -0
  24. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/ctrnn.py +0 -0
  25. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/double_pendulum.py +0 -0
  26. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/duffing.py +0 -0
  27. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/euler_spiral.py +0 -0
  28. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/fitzhugh_nagumo.py +0 -0
  29. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/hindmarsh_rose.py +0 -0
  30. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/kuramoto.py +0 -0
  31. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/lorenz.py +0 -0
  32. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/lorenz96.py +0 -0
  33. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/lotka_volterra.py +0 -0
  34. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/mackey_glass.py +0 -0
  35. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/morris_lecar.py +0 -0
  36. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/pyloric.py +0 -0
  37. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/rk45.py +0 -0
  38. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/rossler.py +0 -0
  39. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/rulkov_map.py +0 -0
  40. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/three_scroll.py +0 -0
  41. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/metrics/__init__.py +0 -0
  42. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/metrics/mae.py +0 -0
  43. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/metrics/pearson_correlation.py +0 -0
  44. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/metrics/rmse.py +0 -0
  45. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/simplex_projection/__init__.py +0 -0
  46. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/smap/__init__.py +0 -0
  47. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/splits.py +0 -0
  48. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/wavelet/__init__.py +0 -0
  49. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/wavelet/continuous.py +0 -0
  50. {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/wavelet/discrete.py +0 -0
edmkit-0.0.18/PKG-INFO ADDED
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.3
2
+ Name: edmkit
3
+ Version: 0.0.18
4
+ Summary: Simple EDM (Empirical Dynamic Modeling) library
5
+ Author: FUJISHIGE TEMMA
6
+ Author-email: FUJISHIGE TEMMA <tenma.x0@gmail.com>
7
+ Requires-Dist: kdtree-rs>=0.3.1
8
+ Requires-Dist: numpy>=2.5.4
9
+ Requires-Dist: scipy>=1.18.1
10
+ Requires-Dist: tinygrad>=0.14.0
11
+ Requires-Dist: tinysolve>=0.3.0
12
+ Requires-Python: >=3.13
13
+ Description-Content-Type: text/markdown
14
+
15
+ # edmkit
16
+
17
+ This library is a collection of tools and utilities that are useful for Empirical Data Modeling (EDM) and related tasks. The library is designed to be fast and lightweight, and easy to use.
18
+
19
+ [benchmarks](./benchmarks)
20
+
21
+ ![](./benchmarks/benchmarks.png)
22
+
23
+ [compare.py](./benchmarks/compare.py)
24
+
25
+ ![](./benchmarks/compare.png)
26
+
27
+ Measured on an Apple M4 Pro on November 4, 2026. Times are medians from `pytest-benchmark`. Parallel CCM uses 14 workers. Peak memory is process-tree RSS in MiB, sampled separately from timing for each case. It includes the interpreter, input, and worker processes. Summed RSS can count shared pages in more than one process.
28
+
29
+ | simplex N | edmkit [ms] | pyEDM [ms] | speedup | edmkit peak [MiB] | pyEDM peak [MiB] |
30
+ | ---: | ---: | ---: | ---: | ---: | ---: |
31
+ | 500 | 0.17 | 1.5 | 9.0× | 168 | 169 |
32
+ | 1,000 | 0.39 | 2.4 | 6.1× | 169 | 170 |
33
+ | 2,000 | 0.81 | 4.4 | 5.5× | 168 | 170 |
34
+ | 5,000 | 2.2 | 11.5 | 5.3× | 170 | 175 |
35
+ | 10,000 | 4.7 | 23.8 | 5.0× | 172 | 180 |
36
+ | 20,000 | 10.2 | 50.3 | 4.9× | 176 | 193 |
37
+
38
+ | smap N | edmkit [ms] | pyEDM [ms] | speedup | edmkit peak [MiB] | pyEDM peak [MiB] |
39
+ | ---: | ---: | ---: | ---: | ---: | ---: |
40
+ | 500 | 2.1 | 36.6 | 17.9× | 176 | 180 |
41
+ | 1,000 | 8.7 | 128.3 | 14.7× | 192 | 216 |
42
+ | 2,000 | 22.0 | 513.6 | 23.4× | 261 | 357 |
43
+ | 5,000 | 193.9 | 3,376.7 | 17.4× | 744 | 1,344 |
44
+
45
+ | ccm N | edmkit [ms] | pyEDM [ms] | speedup | edmkit parallel [ms] | pyEDM parallel [ms] | speedup |
46
+ | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
47
+ | 500 | 106.4 | 151.7 | 1.4× | 19.5 | 68.0 | 3.5× |
48
+ | 1,000 | 168.4 | 327.0 | 1.9× | 26.3 | 91.9 | 3.5× |
49
+ | 2,000 | 329.7 | 745.1 | 2.3× | 48.8 | 148.3 | 3.0× |
50
+ | 5,000 | 668.9 | 1,945.7 | 2.9× | 97.4 | 397.5 | 4.1× |
51
+ | 10,000 | 1,253.6 | 4,176.7 | 3.3× | 164.6 | 585.4 | 3.6× |
52
+
53
+ | ccm N | edmkit peak [MiB] | pyEDM peak [MiB] | edmkit parallel peak [MiB] | pyEDM parallel peak [MiB] |
54
+ | ---: | ---: | ---: | ---: | ---: |
55
+ | 500 | 171 | 170 | 191 | 1,417 |
56
+ | 1,000 | 173 | 176 | 196 | 1,429 |
57
+ | 2,000 | 179 | 186 | 207 | 1,460 |
58
+ | 5,000 | 204 | 224 | 239 | 1,624 |
59
+ | 10,000 | 230 | 362 | 297 | 1,804 |
60
+
61
+
62
+ ## Installation
63
+
64
+ To install the library, you can use pip:
65
+
66
+ ```bash
67
+ pip install edmkit
68
+ ```
69
+
70
+ Or you can also use [uv](https://docs.astral.sh/uv/):
71
+
72
+ ```bash
73
+ uv add edmkit
74
+ ```
75
+
76
+ ## Testing
77
+
78
+ ```bash
79
+ # Run all tests
80
+ uv run pytest
81
+
82
+ # Skip slow tests
83
+ uv run pytest -m "not slow"
84
+
85
+ # Run with CI profile (more hypothesis examples)
86
+ HYPOTHESIS_PROFILE=ci uv run pytest
87
+ ```
@@ -0,0 +1,73 @@
1
+ # edmkit
2
+
3
+ This library is a collection of tools and utilities that are useful for Empirical Data Modeling (EDM) and related tasks. The library is designed to be fast and lightweight, and easy to use.
4
+
5
+ [benchmarks](./benchmarks)
6
+
7
+ ![](./benchmarks/benchmarks.png)
8
+
9
+ [compare.py](./benchmarks/compare.py)
10
+
11
+ ![](./benchmarks/compare.png)
12
+
13
+ Measured on an Apple M4 Pro on November 4, 2026. Times are medians from `pytest-benchmark`. Parallel CCM uses 14 workers. Peak memory is process-tree RSS in MiB, sampled separately from timing for each case. It includes the interpreter, input, and worker processes. Summed RSS can count shared pages in more than one process.
14
+
15
+ | simplex N | edmkit [ms] | pyEDM [ms] | speedup | edmkit peak [MiB] | pyEDM peak [MiB] |
16
+ | ---: | ---: | ---: | ---: | ---: | ---: |
17
+ | 500 | 0.17 | 1.5 | 9.0× | 168 | 169 |
18
+ | 1,000 | 0.39 | 2.4 | 6.1× | 169 | 170 |
19
+ | 2,000 | 0.81 | 4.4 | 5.5× | 168 | 170 |
20
+ | 5,000 | 2.2 | 11.5 | 5.3× | 170 | 175 |
21
+ | 10,000 | 4.7 | 23.8 | 5.0× | 172 | 180 |
22
+ | 20,000 | 10.2 | 50.3 | 4.9× | 176 | 193 |
23
+
24
+ | smap N | edmkit [ms] | pyEDM [ms] | speedup | edmkit peak [MiB] | pyEDM peak [MiB] |
25
+ | ---: | ---: | ---: | ---: | ---: | ---: |
26
+ | 500 | 2.1 | 36.6 | 17.9× | 176 | 180 |
27
+ | 1,000 | 8.7 | 128.3 | 14.7× | 192 | 216 |
28
+ | 2,000 | 22.0 | 513.6 | 23.4× | 261 | 357 |
29
+ | 5,000 | 193.9 | 3,376.7 | 17.4× | 744 | 1,344 |
30
+
31
+ | ccm N | edmkit [ms] | pyEDM [ms] | speedup | edmkit parallel [ms] | pyEDM parallel [ms] | speedup |
32
+ | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
33
+ | 500 | 106.4 | 151.7 | 1.4× | 19.5 | 68.0 | 3.5× |
34
+ | 1,000 | 168.4 | 327.0 | 1.9× | 26.3 | 91.9 | 3.5× |
35
+ | 2,000 | 329.7 | 745.1 | 2.3× | 48.8 | 148.3 | 3.0× |
36
+ | 5,000 | 668.9 | 1,945.7 | 2.9× | 97.4 | 397.5 | 4.1× |
37
+ | 10,000 | 1,253.6 | 4,176.7 | 3.3× | 164.6 | 585.4 | 3.6× |
38
+
39
+ | ccm N | edmkit peak [MiB] | pyEDM peak [MiB] | edmkit parallel peak [MiB] | pyEDM parallel peak [MiB] |
40
+ | ---: | ---: | ---: | ---: | ---: |
41
+ | 500 | 171 | 170 | 191 | 1,417 |
42
+ | 1,000 | 173 | 176 | 196 | 1,429 |
43
+ | 2,000 | 179 | 186 | 207 | 1,460 |
44
+ | 5,000 | 204 | 224 | 239 | 1,624 |
45
+ | 10,000 | 230 | 362 | 297 | 1,804 |
46
+
47
+
48
+ ## Installation
49
+
50
+ To install the library, you can use pip:
51
+
52
+ ```bash
53
+ pip install edmkit
54
+ ```
55
+
56
+ Or you can also use [uv](https://docs.astral.sh/uv/):
57
+
58
+ ```bash
59
+ uv add edmkit
60
+ ```
61
+
62
+ ## Testing
63
+
64
+ ```bash
65
+ # Run all tests
66
+ uv run pytest
67
+
68
+ # Skip slow tests
69
+ uv run pytest -m "not slow"
70
+
71
+ # Run with CI profile (more hypothesis examples)
72
+ HYPOTHESIS_PROFILE=ci uv run pytest
73
+ ```
@@ -1,15 +1,15 @@
1
1
  [project]
2
2
  name = "edmkit"
3
- version = "0.0.16"
3
+ version = "0.0.18"
4
4
  description = "Simple EDM (Empirical Dynamic Modeling) library"
5
5
  readme = "README.md"
6
6
  requires-python = ">= 3.13"
7
7
  dependencies = [
8
8
  "kdtree-rs>=0.3.1",
9
- "numpy>=2.5.1",
10
- "scipy>=1.17.1",
9
+ "numpy>=2.5.4",
10
+ "scipy>=1.18.1",
11
11
  "tinygrad>=0.14.0",
12
- "tinysolve>=0.2.1",
12
+ "tinysolve>=0.3.0",
13
13
  ]
14
14
 
15
15
  [[project.authors]]
@@ -17,14 +17,19 @@ name = "FUJISHIGE TEMMA"
17
17
  email = "tenma.x0@gmail.com"
18
18
 
19
19
  [dependency-groups]
20
+ benchmark = [
21
+ "pandas>=3.1.0",
22
+ "pyedm>=2.5.7",
23
+ ]
20
24
  dev = [
21
- "griffe2md>=1.5.0",
22
- "hypothesis>=6.151.9",
25
+ "griffe2md>=1.5.1",
26
+ "hypothesis>=6.170.14",
23
27
  "matplotlib>=3.11.2",
24
- "pytest>=9.1.1",
28
+ "pytest>=9.2.0",
29
+ "pytest-benchmark>=5.3.1",
25
30
  "pywavelets>=1.10.0",
26
- "ruff>=0.15.20",
27
- "ty>=0.0.56",
31
+ "ruff>=0.17.3",
32
+ "ty>=0.0.95",
28
33
  ]
29
34
 
30
35
  [tool.uv]
@@ -36,10 +41,17 @@ namespace = true
36
41
 
37
42
  [tool.pytest.ini_options]
38
43
  addopts = "-v --tb=short"
44
+ testpaths = ["tests"]
45
+ python_files = [
46
+ "test_*.py",
47
+ "bench_*.py",
48
+ ]
39
49
  filterwarnings = ["error"]
40
50
  markers = [
41
51
  """slow: marks tests as slow (deselect with '-m "not slow"')""",
42
52
  """gpu: marks tests requiring tinygrad tensor backend (deselect with '-m "not gpu"')""",
53
+ "dense: marks benchmarks that build an (M, N) matrix",
54
+ """huge: marks benchmarks whose N is too large for an (M, N) matrix in memory (deselect with '-m "not (dense and huge)"')""",
43
55
  ]
44
56
 
45
57
  [build-system]
@@ -1,27 +1,32 @@
1
1
  [project]
2
2
  name = "edmkit"
3
- version = "0.0.16"
3
+ version = "0.0.18"
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"
7
7
  requires-python = ">= 3.13"
8
8
  dependencies = [
9
9
  "kdtree-rs>=0.3.1",
10
- "numpy>=2.5.1",
11
- "scipy>=1.17.1",
10
+ "numpy>=2.5.4",
11
+ "scipy>=1.18.1",
12
12
  "tinygrad>=0.14.0",
13
- "tinysolve>=0.2.1",
13
+ "tinysolve>=0.3.0",
14
14
  ]
15
15
 
16
16
  [dependency-groups]
17
+ benchmark = [
18
+ "pandas>=3.1.0",
19
+ "pyedm>=2.5.7",
20
+ ]
17
21
  dev = [
18
- "griffe2md>=1.5.0",
19
- "hypothesis>=6.151.9",
22
+ "griffe2md>=1.5.1",
23
+ "hypothesis>=6.170.14",
20
24
  "matplotlib>=3.11.2",
21
- "pytest>=9.1.1",
25
+ "pytest>=9.2.0",
26
+ "pytest-benchmark>=5.3.1",
22
27
  "pywavelets>=1.10.0",
23
- "ruff>=0.15.20",
24
- "ty>=0.0.56",
28
+ "ruff>=0.17.3",
29
+ "ty>=0.0.95",
25
30
  ]
26
31
 
27
32
  [tool.uv]
@@ -31,10 +36,14 @@ required-environments = [
31
36
 
32
37
  [tool.pytest.ini_options]
33
38
  addopts = "-v --tb=short"
39
+ testpaths = ["tests"]
40
+ python_files = ["test_*.py", "bench_*.py"]
34
41
  filterwarnings = ["error"]
35
42
  markers = [
36
43
  "slow: marks tests as slow (deselect with '-m \"not slow\"')",
37
44
  "gpu: marks tests requiring tinygrad tensor backend (deselect with '-m \"not gpu\"')",
45
+ "dense: marks benchmarks that build an (M, N) matrix",
46
+ "huge: marks benchmarks whose N is too large for an (M, N) matrix in memory (deselect with '-m \"not (dense and huge)\"')",
38
47
  ]
39
48
 
40
49
 
@@ -0,0 +1,168 @@
1
+ from collections.abc import Callable, Iterator
2
+ from functools import partial
3
+
4
+ import numpy as np
5
+
6
+ from edmkit.types import Mask, PredictFunc
7
+
8
+ __all__ = ["bootstrap"]
9
+
10
+
11
+ def bootstrap(
12
+ X: np.ndarray,
13
+ Y: np.ndarray,
14
+ Q: np.ndarray,
15
+ lib_sizes: np.ndarray,
16
+ *,
17
+ predict_func: PredictFunc,
18
+ n_samples: int = 20,
19
+ replace: bool = True,
20
+ seed: int | None = 42,
21
+ mask: Mask | None = None,
22
+ batch_size: int | None = 20,
23
+ ) -> Iterator[tuple[tuple[int, slice], Callable[[], np.ndarray]]]:
24
+ """
25
+ Iterate `predict_func(X[library], Y[library], Q)` for `n_samples` times for each library size in `lib_sizes`.
26
+
27
+ By gathering the results of the returned tasks, you can get `(len(lib_sizes), n_samples, M, E')` array of predictions.
28
+
29
+ Parameters
30
+ ----------
31
+ X : np.ndarray
32
+ The input data of shape (N,) or (N, E)
33
+ Y : np.ndarray
34
+ The target data of shape (N,) or (N, E'), sampled together with `X`.
35
+ Q : np.ndarray
36
+ The query points of shape (M,) or (M, E), shared by all sampled libraries as a read-only view.
37
+ lib_sizes : np.ndarray
38
+ The 1D array of library sizes, yielded in the given order. Sizes may exceed N only if `replace` is True.
39
+ predict_func : PredictFunc
40
+ The batched prediction function, such as `simplex_projection` or ``functools.partial(smap, theta=...)``.
41
+ n_samples : int, default 20
42
+ The number of libraries to sample for each size in `lib_sizes`.
43
+ replace : bool, default True
44
+ Whether to sample the library points with replacement.
45
+ seed : int or None, default 42
46
+ The seed of the random generator. If None, uses fresh entropy.
47
+ mask : Mask or None
48
+ ``mask(i, j)`` indicating, for each query in `Q`, which points of `X` to include. Use `theiler_window` for temporal exclusion.
49
+ batch_size : int or None, default 20
50
+ The maximum number of libraries per task. If None, uses `n_samples`.
51
+
52
+ Yields
53
+ ------
54
+ index : tuple[int, slice]
55
+ The position of the task's libraries in an array of shape (len(`lib_sizes`), `n_samples`), such as the scores.
56
+ samples : Callable[[], np.ndarray]
57
+ The task that returns the predictions on B sampled libraries, of shape (B, M, E'), where B <= `batch_size` and E' = 1 if `Y` is 1D.
58
+
59
+ Raises
60
+ ------
61
+ ValueError
62
+ - If `X`, `Y`, or `Q` is empty or not 1D or 2D.
63
+ - If the input arrays `X` and `Y` do not have the same number of points.
64
+ - If the dimension of `X` and `Q` do not match.
65
+ - If `lib_sizes` is not a nonempty 1D array of positive integers.
66
+ - If some size in `lib_sizes` exceeds N when `replace` is False.
67
+ - If `n_samples` or `batch_size` is not positive.
68
+ - If `predict_func` raises when a task is executed.
69
+
70
+ Examples
71
+ --------
72
+ ```python
73
+ from concurrent.futures import ThreadPoolExecutor
74
+
75
+ import numpy as np
76
+
77
+ from edmkit.ccm import bootstrap
78
+ from edmkit.embedding import embed
79
+ from edmkit.metrics import pearson_correlation
80
+ from edmkit.simplex_projection import simplex_projection
81
+
82
+ # Generate a simple time series (logistic map)
83
+ N = 300
84
+ x = np.zeros(N)
85
+ x[0] = 0.4
86
+ for i in range(1, N):
87
+ x[i] = 3.9 * x[i - 1] * (1 - x[i - 1])
88
+
89
+ tau = 2
90
+ E = 3
91
+
92
+ coordinates = np.array([[0, tau * j] for j in range(E)])
93
+ embedding, _ = embed(coordinates, x)
94
+ shift = tau * (E - 1)
95
+
96
+ pool_size = 200
97
+ Tp = 1
98
+ X = embedding[:pool_size - shift]
99
+ Y = x[shift + Tp : pool_size + Tp]
100
+ Q = embedding[pool_size - shift : -Tp]
101
+ actual = x[pool_size + Tp :, None]
102
+
103
+ lib_sizes = np.array([10, 50, 190])
104
+ n_samples = 100
105
+
106
+
107
+ def score(samples):
108
+ predictions = samples() # (B, M, 1)
109
+ return pearson_correlation(predictions, np.broadcast_to(actual, predictions.shape))
110
+
111
+
112
+ scores = np.empty((len(lib_sizes), n_samples))
113
+ tasks = bootstrap(X, Y, Q, lib_sizes, predict_func=simplex_projection, n_samples=n_samples)
114
+ indices, samples = zip(*tasks)
115
+ with ThreadPoolExecutor() as executor:
116
+ for index, result in zip(indices, executor.map(score, samples)):
117
+ scores[index] = result
118
+
119
+ for lib_size, correlation in zip(lib_sizes, scores.mean(axis=1)):
120
+ print(f"Correlation (L={lib_size}): {correlation:.3f}")
121
+ ```
122
+ """
123
+ if X.ndim == 1:
124
+ X = X[:, None]
125
+ if Y.ndim == 1:
126
+ Y = Y[:, None]
127
+ if Q.ndim == 1:
128
+ Q = Q[:, None]
129
+
130
+ if X.ndim != 2 or Y.ndim != 2 or Q.ndim != 2:
131
+ raise ValueError(f"X, Y, and Q must be 1D or 2D arrays, got X.ndim={X.ndim}, Y.ndim={Y.ndim}, Q.ndim={Q.ndim}")
132
+ if X.size == 0 or Y.size == 0 or Q.size == 0:
133
+ raise ValueError("X, Y, and Q must be nonempty")
134
+ if X.shape[0] != Y.shape[0]:
135
+ raise ValueError(f"X and Y must have the same length, got X.shape={X.shape}, Y.shape={Y.shape}")
136
+ if X.shape[1] != Q.shape[1]:
137
+ raise ValueError(f"X and Q must have the same dimension, got X.shape={X.shape}, Q.shape={Q.shape}")
138
+ if lib_sizes.ndim != 1 or lib_sizes.size == 0 or not np.issubdtype(lib_sizes.dtype, np.integer) or np.any(lib_sizes <= 0):
139
+ raise ValueError("lib_sizes must be a nonempty 1D integer array of positive sizes")
140
+ if not replace and np.any(lib_sizes > len(X)):
141
+ raise ValueError(f"lib_sizes cannot exceed N={len(X)} when replace is False")
142
+ batch_size = n_samples if batch_size is None else batch_size
143
+ if n_samples <= 0 or batch_size <= 0:
144
+ raise ValueError(f"n_samples and batch_size must be positive, got n_samples={n_samples}, batch_size={batch_size}")
145
+
146
+ rng = np.random.default_rng(seed)
147
+ for i, lib_size in enumerate(lib_sizes):
148
+ for start in range(0, n_samples, batch_size):
149
+ batch = min(batch_size, n_samples - start)
150
+ if replace:
151
+ indices = rng.choice(len(X), size=(batch, lib_size))
152
+ else:
153
+ indices = np.stack([rng.choice(len(X), size=lib_size, replace=False) for _ in range(batch)])
154
+ yield (i, slice(start, start + batch)), partial(samples, X, Y, Q, indices, predict_func=predict_func, mask=mask)
155
+
156
+
157
+ def samples(
158
+ X: np.ndarray,
159
+ Y: np.ndarray,
160
+ Q: np.ndarray,
161
+ indices: np.ndarray,
162
+ *,
163
+ predict_func: PredictFunc,
164
+ mask: Mask | None,
165
+ ) -> np.ndarray:
166
+ queries = np.broadcast_to(Q, (len(indices), *Q.shape))
167
+ # position j of library b is the point indices[b, j] of X
168
+ return predict_func(X[indices], Y[indices], queries, mask=None if mask is None else (lambda b, i, j: mask(i, indices[b, j])))
@@ -0,0 +1,65 @@
1
+ import numpy as np
2
+ from kdtree import KDTree
3
+
4
+ from edmkit.types import Mask
5
+
6
+ __all__: list[str] = []
7
+
8
+
9
+ def knn(X: np.ndarray, Q: np.ndarray, k: int, mask: Mask | None = None) -> tuple[np.ndarray, np.ndarray]:
10
+ """Find the k-nearest neighbors of `Q` in `X` using `kdtree.KDTree`, optionally restricted by `mask`.
11
+
12
+ Parameters
13
+ ----------
14
+ X : np.ndarray
15
+ The input data (N, E)
16
+ Q : np.ndarray
17
+ The query points (M, E)
18
+ k : int
19
+ The number of nearest neighbors to find (typically E+1 for simplex projection).
20
+ mask : Mask or None
21
+ ``mask(i, j)`` is True where X[j] is a valid point for Q[i].
22
+
23
+ Returns
24
+ -------
25
+ distances : np.ndarray
26
+ The distances from each query point in `Q` to its k nearest neighbors in `X`, sorted in ascending order (M, k)
27
+ indices : np.ndarray
28
+ The indices of the k nearest neighbors in `X` for each query point in `Q` (M, k)
29
+ """
30
+ N = X.shape[0]
31
+
32
+ if N < k:
33
+ raise ValueError(f"Not enough points in X to find {k} neighbors, got N={N}")
34
+
35
+ # smaller leaves improve pruning in low dimensions.
36
+ tree = KDTree(X, leafsize=16 if X.shape[-1] <= 8 else 32)
37
+ if mask is None or len(Q) == 0:
38
+ return tree.query(Q, k=k)
39
+
40
+ distances = np.empty((Q.shape[0], k))
41
+ indices = np.empty((Q.shape[0], k), dtype=np.intp)
42
+
43
+ pending = np.arange(Q.shape[0])
44
+ k_query = min(k + 1, N)
45
+
46
+ while pending.size > 0:
47
+ _distances, _indices = tree.query(Q if pending.size == len(Q) else Q[pending], k=k_query)
48
+
49
+ # evaluate the mask and keep the first k valid candidates.
50
+ valid = np.broadcast_to(np.asarray(mask(pending[:, None], _indices), dtype=bool), _indices.shape)
51
+ count = valid.sum(axis=-1)
52
+
53
+ if k_query == N and count.min() < k:
54
+ raise ValueError(f"Not enough unmasked points in X to find {k} neighbors, got {count.min()} for some query")
55
+
56
+ done = count >= k
57
+ if done.any():
58
+ first_k = valid & (valid.cumsum(axis=-1, dtype=np.uint32) <= k) & done[:, None]
59
+ distances[pending[done]] = _distances[first_k].reshape(-1, k)
60
+ indices[pending[done]] = _indices[first_k].reshape(-1, k)
61
+
62
+ pending = pending[~done]
63
+ k_query = min(2 * k_query, N)
64
+
65
+ return distances, indices
@@ -1,17 +1,20 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from functools import partial
3
4
  from typing import TYPE_CHECKING, overload
4
5
 
5
6
  import numpy as np
6
7
 
7
8
  from edmkit.simplex_projection.knn import knn
8
- from edmkit.util import pairwise_distance
9
+ from edmkit.util import materialize, pairwise_distance, topk
9
10
 
10
11
  __all__ = ["linear_operator"]
11
12
 
12
13
  if TYPE_CHECKING:
13
14
  from tinygrad import Tensor
14
15
 
16
+ from edmkit.types import Mask
17
+
15
18
 
16
19
  @overload
17
20
  def linear_operator(
@@ -19,7 +22,7 @@ def linear_operator(
19
22
  Q: np.ndarray,
20
23
  *,
21
24
  k: int | None = None,
22
- mask: np.ndarray | None = None,
25
+ mask: Mask | None = None,
23
26
  ) -> np.ndarray: ...
24
27
 
25
28
 
@@ -29,7 +32,7 @@ def linear_operator(
29
32
  Q: Tensor,
30
33
  *,
31
34
  k: int | None = None,
32
- mask: Tensor | None = None,
35
+ mask: Mask | None = None,
33
36
  ) -> Tensor: ...
34
37
 
35
38
 
@@ -51,8 +54,8 @@ def linear_operator(
51
54
  The query points of shape (M,) or (M, E) or (B, M, E) for which to find the nearest neighbors in `X`.
52
55
  k : int or None, default None
53
56
  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.
57
+ mask : Mask or None
58
+ ``mask(i, j)``, or ``mask(b, i, j)``, indicating for each query in `Q` which library points to include when finding nearest neighbors.
56
59
 
57
60
  Returns
58
61
  -------
@@ -64,7 +67,6 @@ def linear_operator(
64
67
  ValueError
65
68
  - If `k` is not positive.
66
69
  - 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
70
  - If fewer than `k` library points are unmasked for some query.
69
71
 
70
72
  Examples
@@ -93,7 +95,7 @@ def _numpy(
93
95
  Q: np.ndarray,
94
96
  *,
95
97
  k: int | None = None,
96
- mask: np.ndarray | None = None,
98
+ mask: Mask | None = None,
97
99
  ) -> np.ndarray:
98
100
  # ensure at least 2D
99
101
  if X.ndim == 1:
@@ -105,8 +107,6 @@ def _numpy(
105
107
  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
108
  if Q.shape[:-2] != X.shape[:-2] or Q.shape[-1] != X.shape[-1]:
107
109
  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
110
 
111
111
  return_ndim = X.ndim
112
112
 
@@ -114,7 +114,7 @@ def _numpy(
114
114
  if X.ndim == 2:
115
115
  X, Q = X[None], Q[None]
116
116
  if mask is not None:
117
- mask = mask[None]
117
+ mask = lambda _, *index, mask=mask: mask(*index)
118
118
 
119
119
  B, N, E = X.shape
120
120
  M = Q.shape[1]
@@ -125,19 +125,7 @@ def _numpy(
125
125
  distances = np.empty((B, M, k))
126
126
  indices = np.empty((B, M, k), dtype=np.intp)
127
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)
128
+ distances[b], indices[b] = knn(X[b], Q[b], k, None if mask is None else partial(mask, b))
141
129
 
142
130
  # clamp to avoid division by zero
143
131
  d_min = np.fmax(distances.min(axis=-1, keepdims=True), 1e-6) # (B, M, 1)
@@ -157,8 +145,10 @@ def _tensor(
157
145
  Q: Tensor,
158
146
  *,
159
147
  k: int | None = None,
160
- mask: Tensor | None = None,
148
+ mask: Mask | None = None,
161
149
  ) -> Tensor:
150
+ from tinygrad import Tensor
151
+
162
152
  # ensure at least 2D
163
153
  if X.ndim == 1:
164
154
  X = X[:, None]
@@ -169,16 +159,15 @@ def _tensor(
169
159
  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
160
  if Q.shape[:-2] != X.shape[:-2] or Q.shape[-1] != X.shape[-1]:
171
161
  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}")
162
+ _mask = None if mask is None else Tensor(materialize(mask, Q.shape[:-1] + X.shape[-2:-1]))
174
163
 
175
164
  return_ndim = X.ndim
176
165
 
177
166
  # treat the unbatched case as a single batch
178
167
  if X.ndim == 2:
179
168
  X, Q = X[None], Q[None]
180
- if mask is not None:
181
- mask = mask[None]
169
+ if _mask is not None:
170
+ _mask = _mask[None]
182
171
 
183
172
  B, N, E = (int(X.shape[0]), int(X.shape[1]), int(X.shape[2]))
184
173
  M = int(Q.shape[1])
@@ -189,10 +178,10 @@ def _tensor(
189
178
  # clamp to avoid NaN gradient
190
179
  D = pairwise_distance(Q, X).clamp(min_=1e-12).sqrt() # (B, M, N)
191
180
 
192
- if mask is not None:
193
- D = mask.where(D, float("inf"))
181
+ if _mask is not None:
182
+ D = _mask.where(D, float("inf"))
194
183
 
195
- distances, indices = D.topk(k, dim=-1, largest=False, sorted_=True) # (B, M, k)
184
+ distances, indices = topk(D, k, largest=False) # (B, M, k)
196
185
 
197
186
  # neighbors are sorted, so the first is the nearest; clamp to avoid division by zero
198
187
  d_min = distances[..., :1].clamp(min_=1e-6) # (B, M, 1)