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.
- edmkit-0.0.18/PKG-INFO +87 -0
- edmkit-0.0.18/README.md +73 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/pyproject.toml +21 -9
- {edmkit-0.0.16 → edmkit-0.0.18}/pyproject.toml.orig +18 -9
- edmkit-0.0.18/src/edmkit/ccm.py +168 -0
- edmkit-0.0.18/src/edmkit/simplex_projection/knn.py +65 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/simplex_projection/linear_operator.py +20 -31
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/simplex_projection/simplex_projection.py +28 -44
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/simplex_projection/soft.py +27 -26
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/simplex_projection/soft_linear_operator.py +23 -22
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/smap/coefficients.py +35 -27
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/smap/linear_operator.py +36 -27
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/smap/smap.py +8 -7
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/theiler_window.py +15 -8
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/types.py +10 -3
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/util.py +69 -2
- edmkit-0.0.16/PKG-INFO +0 -48
- edmkit-0.0.16/README.md +0 -34
- edmkit-0.0.16/src/edmkit/ccm.py +0 -274
- edmkit-0.0.16/src/edmkit/simplex_projection/knn.py +0 -33
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/embedding.py +0 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/__init__.py +0 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/chua.py +0 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/ctrnn.py +0 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/double_pendulum.py +0 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/duffing.py +0 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/euler_spiral.py +0 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/fitzhugh_nagumo.py +0 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/hindmarsh_rose.py +0 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/kuramoto.py +0 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/lorenz.py +0 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/lorenz96.py +0 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/lotka_volterra.py +0 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/mackey_glass.py +0 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/morris_lecar.py +0 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/pyloric.py +0 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/rk45.py +0 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/rossler.py +0 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/rulkov_map.py +0 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/generate/three_scroll.py +0 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/metrics/__init__.py +0 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/metrics/mae.py +0 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/metrics/pearson_correlation.py +0 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/metrics/rmse.py +0 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/simplex_projection/__init__.py +0 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/smap/__init__.py +0 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/splits.py +0 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/wavelet/__init__.py +0 -0
- {edmkit-0.0.16 → edmkit-0.0.18}/src/edmkit/wavelet/continuous.py +0 -0
- {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
|
+

|
|
22
|
+
|
|
23
|
+
[compare.py](./benchmarks/compare.py)
|
|
24
|
+
|
|
25
|
+

|
|
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
|
+
```
|
edmkit-0.0.18/README.md
ADDED
|
@@ -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
|
+

|
|
8
|
+
|
|
9
|
+
[compare.py](./benchmarks/compare.py)
|
|
10
|
+
|
|
11
|
+

|
|
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.
|
|
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.
|
|
10
|
-
"scipy>=1.
|
|
9
|
+
"numpy>=2.5.4",
|
|
10
|
+
"scipy>=1.18.1",
|
|
11
11
|
"tinygrad>=0.14.0",
|
|
12
|
-
"tinysolve>=0.
|
|
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.
|
|
22
|
-
"hypothesis>=6.
|
|
25
|
+
"griffe2md>=1.5.1",
|
|
26
|
+
"hypothesis>=6.170.14",
|
|
23
27
|
"matplotlib>=3.11.2",
|
|
24
|
-
"pytest>=9.
|
|
28
|
+
"pytest>=9.2.0",
|
|
29
|
+
"pytest-benchmark>=5.3.1",
|
|
25
30
|
"pywavelets>=1.10.0",
|
|
26
|
-
"ruff>=0.
|
|
27
|
-
"ty>=0.0.
|
|
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.
|
|
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.
|
|
11
|
-
"scipy>=1.
|
|
10
|
+
"numpy>=2.5.4",
|
|
11
|
+
"scipy>=1.18.1",
|
|
12
12
|
"tinygrad>=0.14.0",
|
|
13
|
-
"tinysolve>=0.
|
|
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.
|
|
19
|
-
"hypothesis>=6.
|
|
22
|
+
"griffe2md>=1.5.1",
|
|
23
|
+
"hypothesis>=6.170.14",
|
|
20
24
|
"matplotlib>=3.11.2",
|
|
21
|
-
"pytest>=9.
|
|
25
|
+
"pytest>=9.2.0",
|
|
26
|
+
"pytest-benchmark>=5.3.1",
|
|
22
27
|
"pywavelets>=1.10.0",
|
|
23
|
-
"ruff>=0.
|
|
24
|
-
"ty>=0.0.
|
|
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:
|
|
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:
|
|
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 :
|
|
55
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
181
|
-
|
|
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
|
|
193
|
-
D =
|
|
181
|
+
if _mask is not None:
|
|
182
|
+
D = _mask.where(D, float("inf"))
|
|
194
183
|
|
|
195
|
-
distances, indices =
|
|
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)
|