edmkit 0.0.12__tar.gz → 0.0.13__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 (27) hide show
  1. {edmkit-0.0.12 → edmkit-0.0.13}/PKG-INFO +3 -3
  2. {edmkit-0.0.12 → edmkit-0.0.13}/pyproject.toml +3 -3
  3. {edmkit-0.0.12 → edmkit-0.0.13}/pyproject.toml.orig +3 -3
  4. edmkit-0.0.13/src/edmkit/ccm.py +274 -0
  5. edmkit-0.0.13/src/edmkit/embedding.py +85 -0
  6. {edmkit-0.0.12 → edmkit-0.0.13}/src/edmkit/generate/__init__.py +1 -2
  7. edmkit-0.0.13/src/edmkit/metrics/__init__.py +35 -0
  8. edmkit-0.0.13/src/edmkit/metrics/mae.py +77 -0
  9. edmkit-0.0.13/src/edmkit/metrics/pearson_correlation.py +96 -0
  10. edmkit-0.0.13/src/edmkit/metrics/rmse.py +79 -0
  11. {edmkit-0.0.12 → edmkit-0.0.13}/src/edmkit/simplex_projection/__init__.py +1 -2
  12. {edmkit-0.0.12 → edmkit-0.0.13}/src/edmkit/simplex_projection/simplex_projection.py +26 -15
  13. {edmkit-0.0.12 → edmkit-0.0.13}/src/edmkit/simplex_projection/soft.py +13 -7
  14. {edmkit-0.0.12 → edmkit-0.0.13}/src/edmkit/smap.py +21 -15
  15. {edmkit-0.0.12 → edmkit-0.0.13}/src/edmkit/splits.py +3 -2
  16. edmkit-0.0.13/src/edmkit/theiler_window.py +34 -0
  17. {edmkit-0.0.12 → edmkit-0.0.13}/src/edmkit/util.py +1 -1
  18. edmkit-0.0.12/src/edmkit/ccm.py +0 -508
  19. edmkit-0.0.12/src/edmkit/embedding.py +0 -246
  20. edmkit-0.0.12/src/edmkit/metrics.py +0 -132
  21. edmkit-0.0.12/src/edmkit/simplex_projection/loo.py +0 -91
  22. {edmkit-0.0.12 → edmkit-0.0.13}/README.md +0 -0
  23. {edmkit-0.0.12 → edmkit-0.0.13}/src/edmkit/generate/double_pendulum.py +0 -0
  24. {edmkit-0.0.12 → edmkit-0.0.13}/src/edmkit/generate/lorenz.py +0 -0
  25. {edmkit-0.0.12 → edmkit-0.0.13}/src/edmkit/generate/mackey_glass.py +0 -0
  26. {edmkit-0.0.12 → edmkit-0.0.13}/src/edmkit/simplex_projection/knn.py +0 -0
  27. {edmkit-0.0.12 → edmkit-0.0.13}/src/edmkit/types.py +0 -0
@@ -1,14 +1,14 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: edmkit
3
- Version: 0.0.12
3
+ Version: 0.0.13
4
4
  Summary: Simple EDM (Empirical Dynamic Modeling) library
5
5
  Author: FUJISHIGE TEMMA
6
6
  Author-email: FUJISHIGE TEMMA <tenma.x0@gmail.com>
7
7
  Requires-Dist: kdtree-rs>=0.3.1
8
8
  Requires-Dist: numpy>=2.5.1
9
9
  Requires-Dist: scipy>=1.17.1
10
- Requires-Dist: tinygrad>=0.13.0
11
- Requires-Dist: tinysolve>=0.2.0
10
+ Requires-Dist: tinygrad>=0.14.0
11
+ Requires-Dist: tinysolve>=0.2.1
12
12
  Requires-Python: >=3.13
13
13
  Description-Content-Type: text/markdown
14
14
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "edmkit"
3
- version = "0.0.12"
3
+ version = "0.0.13"
4
4
  description = "Simple EDM (Empirical Dynamic Modeling) library"
5
5
  readme = "README.md"
6
6
  requires-python = ">= 3.13"
@@ -8,8 +8,8 @@ dependencies = [
8
8
  "kdtree-rs>=0.3.1",
9
9
  "numpy>=2.5.1",
10
10
  "scipy>=1.17.1",
11
- "tinygrad>=0.13.0",
12
- "tinysolve>=0.2.0",
11
+ "tinygrad>=0.14.0",
12
+ "tinysolve>=0.2.1",
13
13
  ]
14
14
 
15
15
  [[project.authors]]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "edmkit"
3
- version = "0.0.12"
3
+ version = "0.0.13"
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"
@@ -9,8 +9,8 @@ dependencies = [
9
9
  "kdtree-rs>=0.3.1",
10
10
  "numpy>=2.5.1",
11
11
  "scipy>=1.17.1",
12
- "tinygrad>=0.13.0",
13
- "tinysolve>=0.2.0",
12
+ "tinygrad>=0.14.0",
13
+ "tinysolve>=0.2.1",
14
14
  ]
15
15
 
16
16
  [dependency-groups]
@@ -0,0 +1,274 @@
1
+ from collections.abc import Callable
2
+
3
+ import numpy as np
4
+
5
+ from edmkit.metrics import MetricFunc
6
+ from edmkit.types import PredictFunc
7
+
8
+ __all__ = [
9
+ "AggregateFunc",
10
+ "SampleFunc",
11
+ "bootstrap",
12
+ "ccm",
13
+ "make_sample_func",
14
+ ]
15
+
16
+ type SampleFunc = Callable[[np.ndarray, int], np.ndarray]
17
+ """SampleFunc is a function that takes (pool, size) and returns a sampled array."""
18
+ type AggregateFunc = Callable[[np.ndarray], float]
19
+ """AggregateFunc is a function that takes an array of values and returns a single value."""
20
+
21
+
22
+ def make_sample_func(seed: int | None = 42) -> SampleFunc:
23
+ """Create a sample function with its own independent RNG."""
24
+ rng = np.random.default_rng(seed)
25
+
26
+ def sample_func(pool: np.ndarray, size: int) -> np.ndarray:
27
+ return rng.choice(pool, size=size, replace=True)
28
+
29
+ return sample_func
30
+
31
+
32
+ def bootstrap(
33
+ X: np.ndarray,
34
+ Y: np.ndarray,
35
+ lib_sizes: np.ndarray,
36
+ predict_func: PredictFunc,
37
+ metric_func: MetricFunc,
38
+ n_samples: int = 20,
39
+ *,
40
+ library_pool: np.ndarray,
41
+ prediction_pool: np.ndarray,
42
+ sample_func: SampleFunc | None = None,
43
+ batch_size: int | None = 20,
44
+ ) -> np.ndarray:
45
+ """
46
+ Perform Convergent Cross Mapping and return per-sample scores.
47
+
48
+ Same as :func:`ccm` but returns the raw per-sample scores instead of
49
+ aggregating them.
50
+
51
+ Parameters
52
+ ----------
53
+ X : np.ndarray
54
+ Library delay vectors of shape ``(T,)`` or ``(T, E)`` (potential response).
55
+ Y : np.ndarray
56
+ Target values of shape ``(T,)`` or ``(T, E')`` (potential driver).
57
+ A 2D `Y` cross-maps to a delay vector; the metric scores all columns jointly.
58
+ lib_sizes : np.ndarray
59
+ Array of library sizes to test convergence.
60
+ predict_func : PredictFunc
61
+ Prediction function with signature (X, Y, Q) -> predictions.
62
+ metric_func : MetricFunc
63
+ Metric function with signature (predictions, observations) -> metric value.
64
+ n_samples : int, default 20
65
+ Number of random samples per library size for bootstrapping.
66
+ library_pool : np.ndarray
67
+ 1-D array of integer indices from which library members are sampled.
68
+ prediction_pool : np.ndarray
69
+ 1-D array of integer indices that are predicted.
70
+ sample_func : SampleFunc or None, default None
71
+ Function responsible for drawing a library sample of a given size.
72
+ When None, a fresh RNG-backed sampler is created per call.
73
+ batch_size : int or None, default 20
74
+ If specified, predictions are made in batches to limit memory usage.
75
+
76
+ Returns
77
+ -------
78
+ samples : np.ndarray
79
+ Per-sample skill scores of shape ``(n_samples, len(lib_sizes))``.
80
+ """
81
+ if sample_func is None:
82
+ sample_func = make_sample_func()
83
+
84
+ if X.shape[0] != Y.shape[0]:
85
+ raise ValueError(f"X and Y must have same length, got {X.shape[0]} and {Y.shape[0]}")
86
+ if predict_func is None or not callable(predict_func):
87
+ raise ValueError(f"predict_func must be callable, got {type(predict_func)}")
88
+ if n_samples <= 0:
89
+ raise ValueError(f"n_samples must be positive, got {n_samples}")
90
+
91
+ if batch_size is None:
92
+ batch_size = n_samples
93
+ else:
94
+ batch_size = min(batch_size, n_samples)
95
+
96
+ if X.ndim == 1:
97
+ X = X[:, None]
98
+ if Y.ndim == 1:
99
+ Y = Y[:, None]
100
+
101
+ prediction_indices = np.tile(prediction_pool, (batch_size, 1))
102
+ Q = X[prediction_indices]
103
+ actual = Y[prediction_indices]
104
+
105
+ samples = np.zeros((n_samples, len(lib_sizes)))
106
+
107
+ for i, lib_size in enumerate(lib_sizes):
108
+ remaining = n_samples
109
+ while remaining > 0:
110
+ batch = min(batch_size, remaining)
111
+
112
+ library_indices = np.vstack([sample_func(library_pool, lib_size) for _ in range(batch)])
113
+
114
+ lib_X = X[library_indices]
115
+ lib_Y = Y[library_indices]
116
+
117
+ predictions = predict_func(lib_X, lib_Y, Q[:batch])
118
+
119
+ offset = n_samples - remaining
120
+ samples[offset : offset + batch, i] = metric_func(predictions, actual[:batch])
121
+ remaining -= batch
122
+
123
+ return samples
124
+
125
+
126
+ def ccm(
127
+ X: np.ndarray,
128
+ Y: np.ndarray,
129
+ lib_sizes: np.ndarray,
130
+ predict_func: PredictFunc,
131
+ metric_func: MetricFunc,
132
+ n_samples: int = 20,
133
+ *,
134
+ library_pool: np.ndarray,
135
+ prediction_pool: np.ndarray,
136
+ sample_func: SampleFunc | None = None,
137
+ aggregate_func: AggregateFunc = np.mean,
138
+ batch_size: int | None = 20,
139
+ ) -> np.ndarray:
140
+ """
141
+ Perform Convergent Cross Mapping using a custom prediction function.
142
+
143
+ CCM tests for causality from X to Y by using the attractor reconstructed from Y
144
+ to predict values of X. If X causes Y, then Y's attractor contains information
145
+ about X, allowing cross-mapping from Y to X.
146
+
147
+ Parameters
148
+ ----------
149
+ X : np.ndarray
150
+ Library delay vectors of shape ``(T,)`` or ``(T, E)`` (potential response).
151
+ Y : np.ndarray
152
+ Target values of shape ``(T,)`` or ``(T, E')`` (potential driver).
153
+ A 2D `Y` cross-maps to a delay vector; the metric scores all columns jointly.
154
+ lib_sizes : np.ndarray
155
+ Array of library sizes to test convergence.
156
+ predict_func : PredictFunc
157
+ Prediction function with signature (X, Y, Q) -> predictions.
158
+ Can be `simplex_projection`, `smap` with partial application, or a custom function.
159
+ metric_func : MetricFunc
160
+ Metric function with signature (predictions, observations) -> metric value.
161
+ n_samples : int, default 100
162
+ Number of random samples per library size for bootstrapping.
163
+ library_pool : np.ndarray
164
+ 1-D array of integer indices from which library members are sampled.
165
+ prediction_pool : np.ndarray
166
+ 1-D array of integer indices that are predicted.
167
+ sample_func : SampleFunc or None, default None
168
+ Function responsible for drawing a library sample of a given size.
169
+ It receives ``(pool, size)`` and returns an array of indices.
170
+ When None, a fresh RNG-backed sampler is created per call.
171
+ aggregate_func : AggregateFunc, default np.mean
172
+ Reducer applied to the skill samples for each library size.
173
+ batch_size : int or None, default 20
174
+ If not specified, batch_size == n_samples.
175
+ If specified, predictions are made in batches to limit memory usage.
176
+ Returns
177
+ -------
178
+ correlations : np.ndarray
179
+ Mean correlation coefficient for each library size.
180
+
181
+ Raises
182
+ ------
183
+ ValueError
184
+ - If `X` and `Y` have different lengths.
185
+ - If `lib_sizes` contains non-positive values.
186
+ - If `predict_func` is not callable.
187
+ - If `n_samples` is not positive.
188
+ - If `aggregate_func` is not callable.
189
+ - If `library_pool` or `prediction_pool` is invalid.
190
+
191
+ Notes
192
+ -----
193
+ - Higher correlation at larger library sizes indicates convergence and suggests X influences Y (X -> Y causality)
194
+ - Convergence is the key signature of causality in CCM
195
+ - The method uses Y's attractor to predict X (cross-mapping)
196
+
197
+ Examples
198
+ --------
199
+ ```python
200
+ from functools import partial
201
+
202
+ import numpy as np
203
+
204
+ from edmkit.ccm import ccm
205
+ from edmkit.embedding import embed
206
+ from edmkit.metrics import pearson_correlation
207
+ from edmkit.simplex_projection import simplex_projection
208
+ from edmkit.smap import smap
209
+
210
+ # Generate coupled logistic maps (X drives Y)
211
+ N = 1000
212
+ rx, ry, Bxy = 3.8, 3.5, 0.02
213
+ X = np.zeros(N)
214
+ Y = np.zeros(N)
215
+ X[0], Y[0] = 0.4, 0.2
216
+ for i in range(1, N):
217
+ X[i] = X[i - 1] * (rx - rx * X[i - 1])
218
+ Y[i] = Y[i - 1] * (ry - ry * Y[i - 1]) + Bxy * X[i - 1]
219
+
220
+ tau = 1
221
+ E = 2
222
+
223
+ # To test X -> Y causality, cross-map from Y's attractor to X
224
+ coordinates = np.array([[0, tau * j] for j in range(E)])
225
+ Y_embedding, _ = embed(coordinates, Y)
226
+ shift = tau * (E - 1)
227
+ X_aligned = X[shift:]
228
+
229
+ library_pool = np.arange(Y_embedding.shape[0] // 2)
230
+ prediction_pool = np.arange(Y_embedding.shape[0] // 2, Y_embedding.shape[0])
231
+
232
+ # logarithmic within range 10 to max library size
233
+ lib_sizes = np.logspace(np.log10(10), np.log10(library_pool[-1]), num=5, dtype=int)
234
+
235
+ # Using simplex projection
236
+ correlations = ccm(
237
+ Y_embedding,
238
+ X_aligned,
239
+ lib_sizes=lib_sizes,
240
+ predict_func=simplex_projection,
241
+ metric_func=pearson_correlation,
242
+ library_pool=library_pool,
243
+ prediction_pool=prediction_pool,
244
+ )
245
+
246
+ # Using S-Map with partial application
247
+ correlations = ccm(
248
+ Y_embedding,
249
+ X_aligned,
250
+ lib_sizes=lib_sizes,
251
+ predict_func=partial(smap, theta=2.0, alpha=1e-10),
252
+ metric_func=pearson_correlation,
253
+ library_pool=library_pool,
254
+ prediction_pool=prediction_pool,
255
+ )
256
+ ```
257
+ """
258
+ if aggregate_func is None or not callable(aggregate_func):
259
+ raise ValueError("aggregate_func must be a callable")
260
+
261
+ samples = bootstrap(
262
+ X,
263
+ Y,
264
+ lib_sizes,
265
+ predict_func,
266
+ metric_func,
267
+ n_samples,
268
+ library_pool=library_pool,
269
+ prediction_pool=prediction_pool,
270
+ sample_func=sample_func,
271
+ batch_size=batch_size,
272
+ )
273
+
274
+ return np.array([aggregate_func(samples[:, i]) for i in range(samples.shape[1])])
@@ -0,0 +1,85 @@
1
+ import numpy as np
2
+
3
+ __all__ = ["embed"]
4
+
5
+
6
+ def embed(coordinates: np.ndarray, x: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
7
+ """Delay vectors for an arbitrary choice of variables and lags.
8
+
9
+ Parameters
10
+ ----------
11
+ coordinates : np.ndarray
12
+ Integer array of shape ``(E, 2)``. Row ``j`` is ``(variable, lag)``, selecting
13
+ observation ``x[t - lag, variable]`` for the delay vector at time ``t``.
14
+ A negative lag selects an observation taken after time ``t``.
15
+ x : np.ndarray
16
+ Observations of shape ``(T, d)``, or ``(T,)``.
17
+
18
+ Returns
19
+ -------
20
+ X : np.ndarray
21
+ Delay vectors of shape ``(N, E)``, one row per time in `t`.
22
+ t : np.ndarray
23
+ Times of shape ``(N,)`` at which every coordinate is available, that is
24
+ ``lags.max() <= t <= T - 1 + lags.min()``. A time outside ``[0, T - 1]`` is
25
+ included when every coordinate still reads an observation inside `x`.
26
+
27
+ Raises
28
+ ------
29
+ ValueError
30
+ - If `coordinates` is not an integer array of shape ``(E, 2)`` with ``E >= 1``.
31
+ - If `x` is not a 1D or 2D array.
32
+ - If a variable index lies outside `x`.
33
+ - If no time has every coordinate available.
34
+
35
+ Examples
36
+ --------
37
+ ```python
38
+ import numpy as np
39
+
40
+ from edmkit.embedding import embed
41
+
42
+ x = np.arange(20).reshape(10, 2) # ten times, two variables
43
+
44
+ # Classical univariate delay coordinates.
45
+ tau, E = 2, 3
46
+ coordinates = np.array([[0, tau * j] for j in range(E)])
47
+ X, t = embed(coordinates, x)
48
+ print(X)
49
+ print(t)
50
+ # [[ 8 4 0]
51
+ # [10 6 2]
52
+ # [12 8 4]
53
+ # [14 10 6]
54
+ # [16 12 8]
55
+ # [18 14 10]]
56
+ # [4 5 6 7 8 9]
57
+
58
+ # One step ahead of the same coordinates, as iterated forecasting needs.
59
+ Y, t_ahead = embed(coordinates - [0, 1], x)
60
+ ```
61
+ """
62
+ coordinates = np.asarray(coordinates)
63
+ if coordinates.ndim != 2 or coordinates.shape[-1] != 2:
64
+ raise ValueError(f"coordinates must have shape (E, 2), got coordinates.shape={coordinates.shape}")
65
+ if coordinates.shape[0] < 1:
66
+ raise ValueError("coordinates must select at least one delay coordinate")
67
+ if not np.issubdtype(coordinates.dtype, np.integer):
68
+ raise ValueError(f"coordinates must be an integer array, got coordinates.dtype={coordinates.dtype}")
69
+ if x.ndim == 1:
70
+ x = x[:, None]
71
+ if x.ndim != 2:
72
+ raise ValueError(f"x must have shape (T,) or (T, d), got x.shape={x.shape}")
73
+
74
+ variables, lags = coordinates[:, 0], coordinates[:, 1]
75
+ if variables.min() < 0 or variables.max() >= x.shape[1]:
76
+ raise ValueError(f"variable indices must lie in [0, {x.shape[1] - 1}], got {variables.min()} to {variables.max()}")
77
+
78
+ # A delay vector at time t needs 0 <= t - lag <= T - 1 for every coordinate.
79
+ first = int(lags.max())
80
+ last = x.shape[0] - 1 + int(lags.min())
81
+ if last < first:
82
+ raise ValueError(f"no time has every coordinate available: lags span {int(lags.min())} to {int(lags.max())} for T={x.shape[0]}")
83
+
84
+ t = np.arange(first, last + 1)
85
+ return x[t[:, None] - lags[None, :], variables[None, :]], t
@@ -1,6 +1,5 @@
1
- # ruff: noqa: F401
2
1
  from .double_pendulum import double_pendulum, to_xy
3
2
  from .lorenz import lorenz
4
3
  from .mackey_glass import mackey_glass
5
4
 
6
- __all__ = ["double_pendulum", "to_xy", "lorenz", "mackey_glass"]
5
+ __all__ = ["double_pendulum", "lorenz", "mackey_glass", "to_xy"]
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Protocol, overload
4
+
5
+ import numpy as np
6
+
7
+ from edmkit.metrics.mae import mae
8
+ from edmkit.metrics.pearson_correlation import pearson_correlation
9
+ from edmkit.metrics.rmse import rmse
10
+
11
+ __all__ = ["MetricFunc", "mae", "pearson_correlation", "rmse"]
12
+
13
+ if TYPE_CHECKING:
14
+ from tinygrad import Tensor
15
+
16
+
17
+ class MetricFunc(Protocol):
18
+ """Metric function protocol.
19
+
20
+ Accepts predictions and observations of the same shape and returns a metric value.
21
+ """
22
+
23
+ @overload
24
+ def __call__(self, predictions: np.ndarray, observations: np.ndarray) -> np.ndarray: ...
25
+
26
+ @overload
27
+ def __call__(self, predictions: Tensor, observations: Tensor) -> Tensor: ...
28
+
29
+
30
+ if TYPE_CHECKING:
31
+ func: MetricFunc
32
+
33
+ func = pearson_correlation
34
+ func = rmse
35
+ func = mae
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, overload
4
+
5
+ import numpy as np
6
+
7
+ __all__ = ["mae"]
8
+
9
+ if TYPE_CHECKING:
10
+ from tinygrad import Tensor
11
+
12
+
13
+ @overload
14
+ def mae(predictions: np.ndarray, observations: np.ndarray) -> np.ndarray: ...
15
+
16
+
17
+ @overload
18
+ def mae(predictions: Tensor, observations: Tensor) -> Tensor: ...
19
+
20
+
21
+ def mae(predictions, observations):
22
+ """Mean Absolute Error.
23
+
24
+ Parameters
25
+ ----------
26
+ predictions : np.ndarray or Tensor
27
+ ``(N,)``, ``(N, D)``, or ``(B, N, D)``.
28
+ observations : np.ndarray or Tensor
29
+ Same shape as `predictions`.
30
+
31
+ Returns
32
+ -------
33
+ np.ndarray or Tensor
34
+ ``()`` for 1D/2D input, ``(B,)`` for 3D input.
35
+
36
+ Raises
37
+ ------
38
+ ValueError
39
+ - If `predictions` and `observations` have different shapes.
40
+ - If the inputs are not 1D, 2D, or 3D.
41
+ """
42
+ if isinstance(predictions, np.ndarray):
43
+ return _numpy(predictions, observations)
44
+
45
+ return _tensor(predictions, observations)
46
+
47
+
48
+ def _numpy(predictions: np.ndarray, observations: np.ndarray) -> np.ndarray:
49
+ if predictions.shape != observations.shape:
50
+ raise ValueError(f"Shape mismatch: predictions {predictions.shape} vs observations {observations.shape}")
51
+ if predictions.ndim not in (1, 2, 3):
52
+ raise ValueError(f"Expected 1D, 2D, or 3D arrays, got {predictions.ndim}D")
53
+
54
+ # treat a 1D series as a single target dimension
55
+ if predictions.ndim == 1:
56
+ predictions = predictions[:, None]
57
+ observations = observations[:, None]
58
+
59
+ # 2D: (N, D) -> (N,) -> ()
60
+ # 3D: (B, N, D) -> (B, N) -> (B,)
61
+ return np.abs(predictions - observations).mean(axis=-1).mean(axis=-1)
62
+
63
+
64
+ def _tensor(predictions: Tensor, observations: Tensor) -> Tensor:
65
+ if predictions.shape != observations.shape:
66
+ raise ValueError(f"Shape mismatch: predictions {predictions.shape} vs observations {observations.shape}")
67
+ if predictions.ndim not in (1, 2, 3):
68
+ raise ValueError(f"Expected 1D, 2D, or 3D tensors, got {predictions.ndim}D")
69
+
70
+ # treat a 1D series as a single target dimension
71
+ if predictions.ndim == 1:
72
+ predictions = predictions.unsqueeze(-1)
73
+ observations = observations.unsqueeze(-1)
74
+
75
+ # 2D: (N, D) -> (N,) -> ()
76
+ # 3D: (B, N, D) -> (B, N) -> (B,)
77
+ return (predictions - observations).abs().mean(axis=-1).mean(axis=-1)
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, overload
4
+
5
+ import numpy as np
6
+
7
+ __all__ = ["pearson_correlation"]
8
+
9
+ if TYPE_CHECKING:
10
+ from tinygrad import Tensor
11
+
12
+
13
+ @overload
14
+ def pearson_correlation(predictions: np.ndarray, observations: np.ndarray) -> np.ndarray: ...
15
+
16
+
17
+ @overload
18
+ def pearson_correlation(predictions: Tensor, observations: Tensor) -> Tensor: ...
19
+
20
+
21
+ def pearson_correlation(predictions, observations):
22
+ """Mean Pearson correlation over the target dimensions.
23
+
24
+ The correlation is computed per target dimension along the sample axis,
25
+ then averaged over the dimensions.
26
+
27
+ Parameters
28
+ ----------
29
+ predictions : np.ndarray or Tensor
30
+ ``(N,)``, ``(N, D)``, or ``(B, N, D)``.
31
+ observations : np.ndarray or Tensor
32
+ Same shape as `predictions`.
33
+
34
+ Returns
35
+ -------
36
+ np.ndarray or Tensor
37
+ ``()`` for 1D/2D input, ``(B,)`` for 3D input.
38
+
39
+ Raises
40
+ ------
41
+ ValueError
42
+ - If `predictions` and `observations` have different shapes.
43
+ - If the inputs are not 1D, 2D, or 3D.
44
+ """
45
+ if isinstance(predictions, np.ndarray):
46
+ return _numpy(predictions, observations)
47
+
48
+ return _tensor(predictions, observations)
49
+
50
+
51
+ def _numpy(predictions: np.ndarray, observations: np.ndarray) -> np.ndarray:
52
+ if predictions.shape != observations.shape:
53
+ raise ValueError(f"Shape mismatch: predictions {predictions.shape} vs observations {observations.shape}")
54
+ if predictions.ndim not in (1, 2, 3):
55
+ raise ValueError(f"Expected 1D, 2D, or 3D arrays, got {predictions.ndim}D")
56
+
57
+ # treat a 1D series as a single target dimension
58
+ if predictions.ndim == 1:
59
+ predictions = predictions[:, None]
60
+ observations = observations[:, None]
61
+
62
+ predictions_centered = predictions - predictions.mean(axis=-2, keepdims=True)
63
+ observations_centered = observations - observations.mean(axis=-2, keepdims=True)
64
+
65
+ numerator = (predictions_centered * observations_centered).sum(axis=-2)
66
+ denominator = np.sqrt((predictions_centered**2).sum(axis=-2) * (observations_centered**2).sum(axis=-2))
67
+
68
+ denominator = np.where(denominator > 0, denominator, np.nan)
69
+
70
+ # 2D: (D,) -> ()
71
+ # 3D: (B, D) -> (B,)
72
+ return (numerator / denominator).mean(axis=-1)
73
+
74
+
75
+ def _tensor(predictions: Tensor, observations: Tensor) -> Tensor:
76
+ if predictions.shape != observations.shape:
77
+ raise ValueError(f"Shape mismatch: predictions {predictions.shape} vs observations {observations.shape}")
78
+ if predictions.ndim not in (1, 2, 3):
79
+ raise ValueError(f"Expected 1D, 2D, or 3D tensors, got {predictions.ndim}D")
80
+
81
+ # treat a 1D series as a single target dimension
82
+ if predictions.ndim == 1:
83
+ predictions = predictions.unsqueeze(-1)
84
+ observations = observations.unsqueeze(-1)
85
+
86
+ predictions_centered = predictions - predictions.mean(axis=-2, keepdim=True)
87
+ observations_centered = observations - observations.mean(axis=-2, keepdim=True)
88
+
89
+ numerator = (predictions_centered * observations_centered).sum(axis=-2)
90
+ denominator = predictions_centered.pow(2).sum(axis=-2) * observations_centered.pow(2).sum(axis=-2)
91
+
92
+ denominator = (denominator > 0).where(denominator.clamp(min_=1e-12).sqrt(), float("nan"))
93
+
94
+ # 2D: (D,) -> ()
95
+ # 3D: (B, D) -> (B,)
96
+ return (numerator / denominator).mean(axis=-1)