complexeva 0.2.2__tar.gz → 0.3.0__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.
@@ -0,0 +1,12 @@
1
+ build
2
+ dist
3
+ complexeva.egg-info
4
+ _dev**
5
+ **.sh
6
+
7
+ __pycache__
8
+ .DS_Store
9
+ **test.py
10
+
11
+ candidate-measures.md
12
+ dev.py
@@ -0,0 +1,14 @@
1
+ # changelog
2
+
3
+ ## 0.3.0 (unreleased)
4
+
5
+ Numerical results of three measures change. To reproduce earlier results, pin `complexeva==0.2.3`.
6
+
7
+ - Breaking: measures are imported from the top level (`from complexeva import hfd`); `complexeva.sequence` is removed.
8
+ - Breaking: removed unexported helpers (`hfd_hilbert_envelope/transform/frequency`, `lempel_ziv_hilbert_envelope/transform/frequency`, `time_series_to_binary`) and the unused `**kwargs` of `lempel_ziv`.
9
+
10
+ - `hfd`, `hfd_hilbert`: default `k_max` is now 10 (was 64), which gives lower error and better discrimination on series of a few hundred points.
11
+ - `hfd`: raises `ValueError` when `k_max` is outside 2 to len(X) // 2 (previously `ZeroDivisionError` or NaN on short series).
12
+ - `hfd`: now returns the standard Higuchi fractal dimension D; 0.2.3 returned D - 1 (missing `/k` in L_m(k)) and skipped the last increment of each subsequence.
13
+ - `lempel_ziv`: now counts the final unfinished component as in Kaspar & Schuster (1987); 0.2.3 undercounted by 1 on most inputs. New `normalize=True` option returns c*log2(n)/n.
14
+ - `lyapunov_exponent`: rewritten as the Rosenstein et al. (1993) largest Lyapunov exponent with a Theiler window; 0.2.3 averaged one-step log distances and depended on signal amplitude. New options `min_tsep`, `trajectory_len`, `fit_offset`.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2026 Don Yin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.5
2
+ Name: complexeva
3
+ Version: 0.3.0
4
+ Summary: Complexity measures for 1D time series: Higuchi fractal dimension, Lempel-Ziv, compression, Lyapunov exponent
5
+ Project-URL: Homepage, https://github.com/Don-Yin/complexeva
6
+ Project-URL: Changelog, https://github.com/Don-Yin/complexeva/blob/main/CHANGELOG.md
7
+ Author-email: Don Yin <don_yin@outlook.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering
14
+ Requires-Python: >=3.14
15
+ Requires-Dist: numba
16
+ Requires-Dist: numpy
17
+ Requires-Dist: scipy
18
+ Provides-Extra: test
19
+ Requires-Dist: pytest; extra == 'test'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # complexeva
23
+
24
+ Complexity measures for 1D time series, implemented with NumPy, SciPy and Numba.
25
+
26
+ ## install
27
+
28
+ ```bash
29
+ pip install complexeva
30
+ ```
31
+
32
+ Requires Python 3.14 or later.
33
+
34
+ ## example
35
+
36
+ ```python
37
+ import numpy as np
38
+ import complexeva
39
+
40
+ x = np.random.default_rng(0).standard_normal(2000) # white noise
41
+
42
+ complexeva.hfd(x) # about 2.0
43
+ complexeva.lempel_ziv(x, normalize=True) # about 1.0
44
+ complexeva.compression_complexity(x) # about 0.96
45
+ complexeva.lyapunov_exponent(x) # largest Lyapunov exponent
46
+ ```
47
+
48
+ ## measures
49
+
50
+ | function | what it returns | main parameters | reference |
51
+ |---|---|---|---|
52
+ | `hfd(X, k_max=10)` | Higuchi fractal dimension, 1 (smooth) to 2 (white noise) | `k_max`: largest time interval, 2 to len(X) // 2; report it with results | Higuchi 1988, Physica D 31:277 |
53
+ | `hfd_hilbert(X, k_max=10)` | `hfd` of the Hilbert envelope (instantaneous amplitude) | as `hfd` | |
54
+ | `lempel_ziv(x, method="median", normalize=False)` | Lempel-Ziv complexity: integer count, or c·log2(n)/n (about 1 for random input) when `normalize=True` | `method`: binarise by `"median"`, `"mean"` or `"diff"` (rising step); a `"0101..."` string is used as is | Kaspar & Schuster 1987, Phys. Rev. A 36:842 |
55
+ | `lempel_ziv_hilbert(x, method="median", normalize=False)` | `lempel_ziv` of the Hilbert envelope | as `lempel_ziv` | |
56
+ | `compression_complexity(data)` | zlib-compressed size / raw size of the array bytes, 0 to about 1 | depends on the array dtype | |
57
+ | `lyapunov_exponent(data, tau=1, m=2, dt=1.0, min_tsep=None, trajectory_len=20, fit_offset=0)` | largest Lyapunov exponent in units of 1/`dt`; positive suggests chaos, about 0 for periodic signals | `tau`, `m`: delay embedding; `min_tsep`: Theiler window (default: mean period); `trajectory_len`: steps fitted | Rosenstein et al. 1993, Physica D 65:117 |
58
+
59
+ All functions take a 1D array. Results changed in 0.3.0; see [CHANGELOG.md](CHANGELOG.md).
60
+
61
+ ## licence
62
+
63
+ MIT
@@ -0,0 +1,42 @@
1
+ # complexeva
2
+
3
+ Complexity measures for 1D time series, implemented with NumPy, SciPy and Numba.
4
+
5
+ ## install
6
+
7
+ ```bash
8
+ pip install complexeva
9
+ ```
10
+
11
+ Requires Python 3.14 or later.
12
+
13
+ ## example
14
+
15
+ ```python
16
+ import numpy as np
17
+ import complexeva
18
+
19
+ x = np.random.default_rng(0).standard_normal(2000) # white noise
20
+
21
+ complexeva.hfd(x) # about 2.0
22
+ complexeva.lempel_ziv(x, normalize=True) # about 1.0
23
+ complexeva.compression_complexity(x) # about 0.96
24
+ complexeva.lyapunov_exponent(x) # largest Lyapunov exponent
25
+ ```
26
+
27
+ ## measures
28
+
29
+ | function | what it returns | main parameters | reference |
30
+ |---|---|---|---|
31
+ | `hfd(X, k_max=10)` | Higuchi fractal dimension, 1 (smooth) to 2 (white noise) | `k_max`: largest time interval, 2 to len(X) // 2; report it with results | Higuchi 1988, Physica D 31:277 |
32
+ | `hfd_hilbert(X, k_max=10)` | `hfd` of the Hilbert envelope (instantaneous amplitude) | as `hfd` | |
33
+ | `lempel_ziv(x, method="median", normalize=False)` | Lempel-Ziv complexity: integer count, or c·log2(n)/n (about 1 for random input) when `normalize=True` | `method`: binarise by `"median"`, `"mean"` or `"diff"` (rising step); a `"0101..."` string is used as is | Kaspar & Schuster 1987, Phys. Rev. A 36:842 |
34
+ | `lempel_ziv_hilbert(x, method="median", normalize=False)` | `lempel_ziv` of the Hilbert envelope | as `lempel_ziv` | |
35
+ | `compression_complexity(data)` | zlib-compressed size / raw size of the array bytes, 0 to about 1 | depends on the array dtype | |
36
+ | `lyapunov_exponent(data, tau=1, m=2, dt=1.0, min_tsep=None, trajectory_len=20, fit_offset=0)` | largest Lyapunov exponent in units of 1/`dt`; positive suggests chaos, about 0 for periodic signals | `tau`, `m`: delay embedding; `min_tsep`: Theiler window (default: mean period); `trajectory_len`: steps fitted | Rosenstein et al. 1993, Physica D 65:117 |
37
+
38
+ All functions take a 1D array. Results changed in 0.3.0; see [CHANGELOG.md](CHANGELOG.md).
39
+
40
+ ## licence
41
+
42
+ MIT
@@ -0,0 +1,6 @@
1
+ from .compression import compression_complexity
2
+ from .higuchi import hfd, hfd_hilbert
3
+ from .lempel_ziv import lempel_ziv, lempel_ziv_hilbert
4
+ from .lyapunov_exponent import lyapunov_exponent
5
+
6
+ __all__ = ["compression_complexity", "hfd", "hfd_hilbert", "lempel_ziv", "lempel_ziv_hilbert", "lyapunov_exponent"]
@@ -0,0 +1,9 @@
1
+ import zlib
2
+
3
+ import numpy as np
4
+
5
+
6
+ def compression_complexity(data: np.ndarray) -> float:
7
+ """zlib-compressed size divided by raw size of the array bytes."""
8
+ raw = data.tobytes()
9
+ return len(zlib.compress(raw)) / len(raw)
@@ -0,0 +1,40 @@
1
+ import numpy
2
+ from numba import njit
3
+ from scipy.signal import hilbert
4
+
5
+
6
+ @njit
7
+ def compute_L_x(X, k_max):
8
+ N = len(X)
9
+ L = numpy.zeros(k_max)
10
+ x = numpy.zeros(k_max)
11
+ for k in range(1, k_max + 1):
12
+ Lk = numpy.zeros(k)
13
+ for m in range(k):
14
+ # Higuchi (1988): M = floor((N - m) / k) increments with 1-based m, i.e. (N - 1 - m) // k here
15
+ Lmk = 0.0
16
+ n_max = (N - 1 - m) // k
17
+ for i in range(1, n_max + 1):
18
+ Lmk += numpy.abs(X[m + i * k] - X[m + (i - 1) * k])
19
+ Lmk *= (N - 1) / (n_max * k) # normalisation factor
20
+ Lk[m] = Lmk / k # curve length L_m(k); without /k the slope is D - 1
21
+ L[k - 1] = numpy.log(Lk.mean())
22
+ x[k - 1] = numpy.log(1.0 / k)
23
+ return x, L
24
+
25
+
26
+ def hfd(X, k_max=10):
27
+ """Higuchi fractal dimension of a 1D numpy array."""
28
+ assert X.ndim == 1, f"X must be a 1d numpy array, got shape {X.shape}"
29
+ if not 2 <= k_max <= len(X) // 2:
30
+ raise ValueError(f"k_max must be between 2 and len(X) // 2 = {len(X) // 2}, got {k_max}")
31
+ x, L = compute_L_x(X, k_max)
32
+ A = numpy.column_stack((x, numpy.ones_like(x)))
33
+ beta, _, _, _ = numpy.linalg.lstsq(A, L, rcond=None)
34
+ return beta[0]
35
+
36
+
37
+ def hfd_hilbert(X, k_max=10):
38
+ """Higuchi fractal dimension of the Hilbert envelope (instantaneous amplitude) of X."""
39
+ assert X.ndim == 1, f"X must be a 1d numpy array, got shape {X.shape}"
40
+ return hfd(numpy.abs(numpy.asarray(hilbert(X))), k_max=k_max)
@@ -0,0 +1,77 @@
1
+ import numpy
2
+ from numba import njit
3
+ from scipy.signal import hilbert
4
+
5
+
6
+ @njit
7
+ def lempel_ziv_core(binary_array):
8
+ """Kaspar-Schuster count of components needed to reproduce a 0/1 integer array."""
9
+ history_pos = 0 # position in history we're comparing against
10
+ current_len = 1 # length of current pattern we're checking
11
+ max_pattern_len = 1 # length of longest matching pattern found
12
+ seq_pos = 1 # current position in sequence
13
+ complexity = 1 # start with 1 as first character is always a new pattern
14
+ seq_len = len(binary_array)
15
+
16
+ while seq_pos + current_len <= seq_len:
17
+ if binary_array[history_pos + current_len - 1] == binary_array[seq_pos + current_len - 1]:
18
+ current_len += 1
19
+ else:
20
+ max_pattern_len = max(max_pattern_len, current_len)
21
+ history_pos += 1
22
+ if history_pos == seq_pos:
23
+ complexity += 1
24
+ seq_pos += max_pattern_len
25
+ history_pos = 0
26
+ current_len = 1
27
+ max_pattern_len = 1
28
+ else:
29
+ current_len = 1
30
+ if current_len > 1: # sequence ended inside a copied component; Kaspar-Schuster counts it
31
+ complexity += 1
32
+ return complexity
33
+
34
+
35
+ def _binarize(time_series, method):
36
+ """0/1 array: above median or mean, or increasing step for 'diff' (median follows doi:10.1101/2021.09.23.461002)."""
37
+ vector = numpy.asarray(time_series, dtype=numpy.float64)
38
+ if method == "diff":
39
+ bits = numpy.diff(vector) > 0
40
+ elif method == "mean":
41
+ bits = vector > numpy.mean(vector)
42
+ elif method == "median":
43
+ bits = vector > numpy.median(vector)
44
+ else:
45
+ raise ValueError("Method must be 'mean', 'median', or 'diff'.")
46
+ return bits.astype(numpy.int32)
47
+
48
+
49
+ def lempel_ziv(time_series, method="median", normalize=False):
50
+ """
51
+ Lempel-Ziv complexity as described in Kaspar and Schuster (1987), Phys. Rev. A.
52
+ Counts the number of distinct patterns that need to be copied to reproduce the sequence.
53
+
54
+ Args:
55
+ time_series: Either a binary string (e.g., "1001110") or numeric array to be binarised
56
+ method: Binarization method if time_series is numeric ('median', 'mean', 'diff')
57
+ normalize: If True, return c * log2(n) / n for the binary sequence of length n (about 1 for random input)
58
+
59
+ Returns: int (raw count) or float (normalized)
60
+ """
61
+ if isinstance(time_series, str) and all(c in "01" for c in time_series):
62
+ binary_array = numpy.array([int(c) for c in time_series], dtype=numpy.int32)
63
+ else:
64
+ binary_array = _binarize(time_series, method)
65
+ complexity = int(lempel_ziv_core(binary_array))
66
+ if not normalize:
67
+ return complexity
68
+ n = len(binary_array)
69
+ return float(complexity * numpy.log2(n) / n) if n > 1 else 0.0
70
+
71
+
72
+ def lempel_ziv_hilbert(time_series, method="median", normalize=False):
73
+ """Lempel-Ziv complexity of the Hilbert envelope (instantaneous amplitude) of a 1D signal."""
74
+ time_series = numpy.asarray(time_series)
75
+ assert time_series.ndim == 1, f"time_series must be a 1d numpy array, got shape {time_series.shape}"
76
+ envelope = numpy.abs(numpy.asarray(hilbert(time_series)))
77
+ return lempel_ziv(envelope, method=method, normalize=normalize)
@@ -0,0 +1,74 @@
1
+ import numpy as np
2
+ from numba import njit
3
+
4
+
5
+ @njit(cache=True)
6
+ def _divergence(Y, min_tsep, trajectory_len):
7
+ """mean log distance between each orbit point and its nearest neighbour after k steps (Rosenstein 1993)"""
8
+ n_traj = Y.shape[0] - trajectory_len + 1
9
+ neighbour = np.empty(n_traj, dtype=np.int64)
10
+ for i in range(n_traj):
11
+ best, best_j = np.inf, -1
12
+ for j in range(n_traj):
13
+ if abs(i - j) <= min_tsep: # Theiler window: skip temporally close points
14
+ continue
15
+ d = np.sum((Y[i] - Y[j]) ** 2)
16
+ if d < best:
17
+ best, best_j = d, j
18
+ neighbour[i] = best_j
19
+ log_div = np.full(trajectory_len, -np.inf)
20
+ for k in range(trajectory_len):
21
+ total, count = 0.0, 0
22
+ for i in range(n_traj):
23
+ d = np.sqrt(np.sum((Y[i + k] - Y[neighbour[i] + k]) ** 2))
24
+ if d > 0:
25
+ total += np.log(d)
26
+ count += 1
27
+ if count > 0:
28
+ log_div[k] = total / count
29
+ return log_div
30
+
31
+
32
+ def _mean_period(x):
33
+ """1 / power-weighted mean frequency, the default Theiler window"""
34
+ psd = np.abs(np.fft.rfft(x - x.mean())) ** 2
35
+ freqs = np.fft.rfftfreq(len(x))
36
+ return int(np.ceil(np.sum(psd[1:]) / np.sum(freqs[1:] * psd[1:])))
37
+
38
+
39
+ def lyapunov_exponent(data, tau=1, m=2, dt=1.0, min_tsep=None, trajectory_len=20, fit_offset=0):
40
+ """
41
+ Largest Lyapunov exponent by the Rosenstein et al. (1993) method.
42
+
43
+ The series is delay-embedded, each point is paired with its nearest neighbour outside a Theiler window,
44
+ and the exponent is the slope of the mean log divergence of those pairs over `trajectory_len` steps.
45
+
46
+ Args:
47
+ data: 1D array-like time series
48
+ tau: embedding delay in samples
49
+ m: embedding dimension
50
+ dt: sampling interval; the result is in units of 1/dt
51
+ min_tsep: Theiler window in samples; defaults to the mean period of the signal
52
+ trajectory_len: number of steps over which divergence is followed and fitted
53
+ fit_offset: number of initial steps excluded from the line fit
54
+
55
+ Returns:
56
+ float: largest Lyapunov exponent (positive suggests chaos, about 0 or below for regular signals)
57
+ """
58
+ x = np.asarray(data, dtype=np.float64)
59
+ if x.ndim != 1:
60
+ raise ValueError(f"data must be 1D, got shape {x.shape}")
61
+ if min_tsep is None:
62
+ min_tsep = min(_mean_period(x), len(x) // 4)
63
+ n_vectors = len(x) - (m - 1) * tau
64
+ n_traj = n_vectors - trajectory_len + 1
65
+ if n_traj < 2 * min_tsep + 2:
66
+ raise ValueError(f"series too short: {n_traj} trajectories for min_tsep={min_tsep}")
67
+ Y = np.stack([x[i * tau : i * tau + n_vectors] for i in range(m)], axis=1)
68
+ log_div = _divergence(Y, min_tsep, trajectory_len)
69
+ ks = np.arange(trajectory_len)[fit_offset:]
70
+ log_div = log_div[fit_offset:]
71
+ finite = np.isfinite(log_div)
72
+ if finite.sum() < 2:
73
+ return -np.inf
74
+ return float(np.polyfit(ks[finite], log_div[finite], 1)[0] / dt)
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "complexeva"
7
+ version = "0.3.0"
8
+ description = "Complexity measures for 1D time series: Higuchi fractal dimension, Lempel-Ziv, compression, Lyapunov exponent"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Don Yin", email = "don_yin@outlook.com" }]
13
+ requires-python = ">=3.14"
14
+ dependencies = ["numpy", "scipy", "numba"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Operating System :: OS Independent",
18
+ "Intended Audience :: Science/Research",
19
+ "Topic :: Scientific/Engineering",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ test = ["pytest"]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/Don-Yin/complexeva"
27
+ Changelog = "https://github.com/Don-Yin/complexeva/blob/main/CHANGELOG.md"
28
+
29
+ [tool.hatch.build.targets.sdist]
30
+ include = ["/complexeva", "/tests", "/README.md", "/CHANGELOG.md", "/LICENSE"]
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
34
+
35
+ [tool.ruff]
36
+ line-length = 128
@@ -0,0 +1,47 @@
1
+ import numpy
2
+ import pytest
3
+
4
+ from complexeva import hfd
5
+
6
+
7
+ def reference_hfd(x, k_max):
8
+ """Literal Higuchi (1988) formula with 1-based m, written for clarity rather than speed."""
9
+ N = len(x)
10
+ log_inv_k, log_L = [], []
11
+ for k in range(1, k_max + 1):
12
+ lengths = []
13
+ for m in range(1, k + 1):
14
+ M = (N - m) // k
15
+ total = sum(abs(x[m - 1 + i * k] - x[m - 1 + (i - 1) * k]) for i in range(1, M + 1))
16
+ lengths.append(total * (N - 1) / (M * k) / k)
17
+ log_inv_k.append(numpy.log(1.0 / k))
18
+ log_L.append(numpy.log(numpy.mean(lengths)))
19
+ return numpy.polyfit(log_inv_k, log_L, 1)[0]
20
+
21
+
22
+ @pytest.mark.parametrize("seed", [0, 1, 2])
23
+ def test_matches_literal_formula(seed):
24
+ x = numpy.random.default_rng(seed).standard_normal(500)
25
+ assert hfd(x, k_max=16) == pytest.approx(reference_hfd(x, 16), abs=1e-10)
26
+
27
+
28
+ def test_k_max_bounds():
29
+ x = numpy.random.default_rng(3).standard_normal(100)
30
+ assert numpy.isfinite(hfd(x, k_max=50))
31
+ for bad in (1, 51):
32
+ with pytest.raises(ValueError):
33
+ hfd(x, k_max=bad)
34
+
35
+
36
+ @pytest.mark.parametrize(
37
+ "name, expected, tol",
38
+ [("white", 2.0, 0.05), ("brown", 1.5, 0.08), ("sine", 1.0, 0.05)],
39
+ )
40
+ def test_known_dimensions(name, expected, tol):
41
+ rng = numpy.random.default_rng(0)
42
+ signals = {
43
+ "white": rng.standard_normal(8192),
44
+ "brown": numpy.cumsum(rng.standard_normal(8192)),
45
+ "sine": numpy.sin(numpy.linspace(0, 20 * numpy.pi, 8192)),
46
+ }
47
+ assert hfd(signals[name]) == pytest.approx(expected, abs=tol)
@@ -0,0 +1,80 @@
1
+ import numpy
2
+ import pytest
3
+
4
+ from complexeva import lempel_ziv, lempel_ziv_hilbert
5
+
6
+
7
+ def reference_kaspar_schuster(s):
8
+ """Kaspar & Schuster (1987) algorithm as transcribed on Wikipedia, for strings of length >= 2."""
9
+ i, k, start, c, k_max, n = 0, 1, 1, 1, 1, len(s)
10
+ while True:
11
+ if s[i + k - 1] == s[start + k - 1]:
12
+ k += 1
13
+ if start + k > n:
14
+ c += 1
15
+ break
16
+ else:
17
+ k_max = max(k, k_max)
18
+ i += 1
19
+ if i == start:
20
+ c += 1
21
+ start += k_max
22
+ if start + 1 > n:
23
+ break
24
+ i, k, k_max = 0, 1, 1
25
+ else:
26
+ k = 1
27
+ return c
28
+
29
+
30
+ @pytest.mark.parametrize(
31
+ "s, expected",
32
+ [("1", 1), ("0000000000", 2), ("0101010101", 3), ("0001101001000101", 6), ("1001111011000010", 6)],
33
+ )
34
+ def test_known_strings(s, expected):
35
+ assert lempel_ziv(s) == expected
36
+
37
+
38
+ def test_matches_reference_on_random_strings():
39
+ rng = numpy.random.default_rng(0)
40
+ for _ in range(3000):
41
+ s = "".join(rng.choice(["0", "1"], size=int(rng.integers(2, 300))))
42
+ assert lempel_ziv(s) == reference_kaspar_schuster(s), s
43
+
44
+
45
+ @pytest.mark.parametrize("method", ["median", "mean", "diff"])
46
+ def test_numeric_input_equals_binarised_string(method):
47
+ x = numpy.random.default_rng(1).standard_normal(400)
48
+ if method == "diff":
49
+ bits = numpy.diff(x) > 0
50
+ else:
51
+ bits = x > (numpy.median(x) if method == "median" else numpy.mean(x))
52
+ assert lempel_ziv(x, method=method) == reference_kaspar_schuster("".join("1" if b else "0" for b in bits))
53
+
54
+
55
+ def test_normalize_default_off_and_formula():
56
+ s = "1001111011000010"
57
+ assert lempel_ziv(s) == 6 and isinstance(lempel_ziv(s), int)
58
+ assert lempel_ziv(s, normalize=True) == pytest.approx(6 * numpy.log2(16) / 16)
59
+ assert lempel_ziv("1", normalize=True) == 0.0
60
+
61
+
62
+ def test_normalized_random_near_one_and_regular_low():
63
+ rng = numpy.random.default_rng(4)
64
+ assert lempel_ziv(rng.standard_normal(20000), normalize=True) == pytest.approx(1.0, abs=0.1)
65
+ assert lempel_ziv(numpy.sin(numpy.linspace(0, 40 * numpy.pi, 20000)), normalize=True) < 0.05
66
+
67
+
68
+ def test_normalize_uses_binary_length_for_diff():
69
+ x = numpy.random.default_rng(5).standard_normal(300)
70
+ raw = lempel_ziv(x, method="diff")
71
+ assert lempel_ziv(x, method="diff", normalize=True) == pytest.approx(raw * numpy.log2(299) / 299)
72
+
73
+
74
+ def test_hilbert_variant_forwards_normalize():
75
+ x = numpy.random.default_rng(6).standard_normal(500)
76
+ assert 0 < lempel_ziv_hilbert(x, normalize=True) < 2
77
+
78
+
79
+ def test_hilbert_variant_runs():
80
+ assert lempel_ziv_hilbert(numpy.random.default_rng(2).standard_normal(500)) > 1
@@ -0,0 +1,49 @@
1
+ import numpy
2
+ import pytest
3
+
4
+ from complexeva import lyapunov_exponent
5
+
6
+
7
+ def logistic(n, x0=0.3):
8
+ x = numpy.empty(n)
9
+ x[0] = x0
10
+ for i in range(1, n):
11
+ x[i] = 4 * x[i - 1] * (1 - x[i - 1])
12
+ return x
13
+
14
+
15
+ def henon(n):
16
+ x, y, out = 0.1, 0.1, numpy.empty(n)
17
+ for i in range(n):
18
+ x, y = 1 - 1.4 * x * x + y, 0.3 * x
19
+ out[i] = x
20
+ return out
21
+
22
+
23
+ def test_logistic_map_is_ln2():
24
+ assert lyapunov_exponent(logistic(3000), m=1, trajectory_len=8) == pytest.approx(numpy.log(2), abs=0.03)
25
+
26
+
27
+ def test_henon_map_known_value():
28
+ assert lyapunov_exponent(henon(3000), m=2, trajectory_len=8) == pytest.approx(0.419, abs=0.03)
29
+
30
+
31
+ def test_regular_signal_is_about_zero():
32
+ x = numpy.sin(numpy.linspace(0, 40 * numpy.pi, 3000))
33
+ assert abs(lyapunov_exponent(x)) < 0.01
34
+
35
+
36
+ def test_scale_and_offset_invariant():
37
+ x = logistic(2000)
38
+ a = lyapunov_exponent(x, m=1, trajectory_len=8)
39
+ assert lyapunov_exponent(1000 * x + 5, m=1, trajectory_len=8) == pytest.approx(a, rel=1e-9)
40
+
41
+
42
+ def test_dt_scales_result():
43
+ x = logistic(2000)
44
+ assert lyapunov_exponent(x, m=1, trajectory_len=8, dt=0.5) == pytest.approx(2 * lyapunov_exponent(x, m=1, trajectory_len=8))
45
+
46
+
47
+ def test_too_short_raises():
48
+ with pytest.raises(ValueError):
49
+ lyapunov_exponent(numpy.arange(10.0), min_tsep=5)
complexeva-0.2.2/PKG-INFO DELETED
@@ -1,28 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: complexeva
3
- Version: 0.2.2
4
- Home-page:
5
- Author: Don Yin
6
- Author-email: don_yin@outlook.com
7
- Description-Content-Type: text/markdown
8
- Dynamic: author
9
- Dynamic: author-email
10
- Dynamic: description
11
- Dynamic: description-content-type
12
-
13
- # overview
14
- implementations of complexity measures in 1D sequences
15
-
16
- # examples
17
- ```python
18
- from complexeva.sequence import lyapunov_exponent, hfd, lempel_ziv, compression_complexity
19
-
20
- signal = numpy.random.rand(1000) # shape: (1000,)
21
- complexity = hfd(signal)
22
- print(complexity)
23
- ```
24
-
25
- # install
26
- ```bash
27
- pip install complexeva
28
- ```
@@ -1,16 +0,0 @@
1
- # overview
2
- implementations of complexity measures in 1D sequences
3
-
4
- # examples
5
- ```python
6
- from complexeva.sequence import lyapunov_exponent, hfd, lempel_ziv, compression_complexity
7
-
8
- signal = numpy.random.rand(1000) # shape: (1000,)
9
- complexity = hfd(signal)
10
- print(complexity)
11
- ```
12
-
13
- # install
14
- ```bash
15
- pip install complexeva
16
- ```
File without changes
@@ -1,6 +0,0 @@
1
- from .higuchi import hfd, hfd_hilbert
2
- from .lempel_ziv import lempel_ziv
3
- from .compression import compression_complexity
4
- from .lyapunov_exponent import lyapunov_exponent
5
-
6
- __all__ = ["compression_complexity", "lyapunov_exponent", "hfd", "hfd_hilbert", "lempel_ziv"]
@@ -1,18 +0,0 @@
1
- #!/Users/donyin/miniconda3/envs/rotation-1/bin/python
2
-
3
- import zlib
4
- import numpy as np
5
-
6
-
7
- def compression_complexity(data: np.ndarray) -> float:
8
- """
9
- calculate compression complexity using zlib compression -> float: compression complexity score
10
- """
11
- data = data.tobytes()
12
- compressed = zlib.compress(data)
13
- return len(compressed) / len(data) # Normalized complexity score
14
-
15
-
16
- if __name__ == "__main__":
17
- signal = np.random.rand(1000) # shape: (1000,)
18
- print(compression_complexity(signal))
@@ -1,75 +0,0 @@
1
- #!/Users/donyin/miniconda3/envs/rotation-1/bin/python
2
-
3
-
4
- import numpy
5
- from numba import njit
6
- from scipy.signal import hilbert
7
-
8
-
9
- @njit
10
- def compute_L_x(X, k_max=16):
11
- k_max = min(k_max, len(X) // 2) if k_max is None else k_max
12
- N = len(X)
13
- L = numpy.zeros(k_max)
14
- x = numpy.zeros(k_max)
15
- for k in range(1, k_max + 1):
16
- Lk = numpy.zeros(k)
17
- for m in range(k):
18
- Lmk = 0.0
19
- n_max = (N - m) // k
20
- for i in range(1, n_max):
21
- Lmk += numpy.abs(X[m + i * k] - X[m + (i - 1) * k])
22
- Lmk *= (N - 1) / (n_max * k)
23
- Lk[m] = Lmk
24
- L[k - 1] = numpy.log(Lk.mean())
25
- x[k - 1] = numpy.log(1.0 / k)
26
- return x, L
27
-
28
-
29
- def hfd(X, k_max=16):
30
- """expect a 1d numpy array as input"""
31
- assert X.ndim == 1, f"X must be a 1d numpy array, got shape {X.shape}"
32
- x, L = compute_L_x(X, k_max)
33
- A = numpy.column_stack((x, numpy.ones_like(x)))
34
- beta, _, _, _ = numpy.linalg.lstsq(A, L, rcond=None)
35
- return beta[0]
36
-
37
-
38
- def hfd_hilbert(X, k_max=16):
39
- """
40
- Calculate Higuchi Fractal Dimension on the instantaneous frequency
41
- obtained from Hilbert transform of the input signal.
42
-
43
- Args:
44
- X (numpy.ndarray): Input 1D signal
45
- k_max (int): Maximum k value for HFD calculation
46
-
47
- Returns:
48
- float: Higuchi Fractal Dimension of the instantaneous frequency
49
- """
50
- assert X.ndim == 1, f"X must be a 1d numpy array, got shape {X.shape}"
51
- analytic_signal = hilbert(X)
52
- instantaneous_phase = numpy.angle(analytic_signal)
53
- instantaneous_freq = numpy.diff(instantaneous_phase)
54
- instantaneous_freq = numpy.append(instantaneous_freq, instantaneous_freq[-1])
55
- return hfd(instantaneous_freq, k_max=k_max)
56
-
57
-
58
- if __name__ == "__main__":
59
- import time
60
-
61
- signal = numpy.random.rand(1000) # shape: (1000,)
62
-
63
- # Test plain HFD speed
64
- start_time = time.time()
65
- complexity_plain = hfd(signal)
66
- plain_time = time.time() - start_time
67
-
68
- # Test Hilbert HFD speed
69
- start_time = time.time()
70
- complexity_hilbert = hfd_hilbert(signal)
71
- hilbert_time = time.time() - start_time
72
-
73
- print(f"Plain HFD: {complexity_plain:.6f}, Time: {plain_time:.6f}s")
74
- print(f"Hilbert HFD: {complexity_hilbert:.6f}, Time: {hilbert_time:.6f}s")
75
- print(f"Speed ratio (Hilbert/Plain): {hilbert_time/plain_time:.2f}x")
@@ -1,76 +0,0 @@
1
- #!/Users/donyin/miniconda3/envs/rotation-1/bin/python
2
-
3
- import numpy
4
-
5
-
6
- def lempel_ziv(time_series, method="median", **kwargs):
7
- """
8
- - compared to other implementations, this implementation is makes the most sense
9
-
10
- Lempel-Ziv complexity as described in Kaspar and Schuster, Phys. Rev. A.
11
- Counts the number of distinct patterns that need to be copied to reproduce the sequence.
12
- Returns: int: The number of distinct patterns (complexity measure)
13
- """
14
- sequence = time_series_to_binary(time_series, method=method)
15
-
16
- history_pos = 0 # position in history we're comparing against
17
- current_len = 1 # length of current pattern we're checking
18
- max_pattern_len = 1 # length of longest matching pattern found
19
- seq_pos = 1 # current position in sequence
20
- complexity = 1 # start with 1 as first character is always a new pattern
21
- seq_len = len(sequence)
22
-
23
- while seq_pos + current_len <= seq_len:
24
- if sequence[history_pos + current_len - 1] == sequence[seq_pos + current_len - 1]:
25
- current_len += 1
26
- else:
27
- if current_len > max_pattern_len:
28
- max_pattern_len = current_len
29
- history_pos += 1
30
- if history_pos == seq_pos:
31
- complexity += 1
32
- seq_pos += max_pattern_len
33
- history_pos = 0
34
- current_len = 1
35
- max_pattern_len = 1
36
- else:
37
- current_len = 1
38
- return complexity
39
-
40
-
41
- def time_series_to_binary(vector, method="median"):
42
- """
43
- - convert a time series to a binary sequence based on a threshold.
44
- - returns: str: A binary sequence as a string of '0's and '1's.
45
- - https://doi.org/10.1101/2021.09.23.461002 this paper used median, making median the default method.
46
- - diff also makes somewhat sense
47
- """
48
-
49
- vector = numpy.array(vector)
50
-
51
- match method:
52
-
53
- case "mean":
54
- threshold = numpy.mean(vector)
55
- binary_seq = "".join(["1" if x > threshold else "0" for x in vector])
56
-
57
- case "median":
58
- threshold = numpy.median(vector)
59
- binary_seq = "".join(["1" if x > threshold else "0" for x in vector])
60
-
61
- case "diff": # this measures whether the value is increasing or decreasing as compared to the previous value
62
- diffs = numpy.diff(vector)
63
- binary_seq = "".join(["1" if x > 0 else "0" for x in diffs])
64
-
65
- case _:
66
- raise ValueError("Method must be 'mean', 'median', or 'diff'.")
67
-
68
- return binary_seq
69
-
70
-
71
- if __name__ == "__main__":
72
- sequence = "1001111011000010"
73
- print(lempel_ziv(sequence))
74
- signal = numpy.random.rand(1000)
75
- binary_sequence = time_series_to_binary(signal, method="median")
76
- print(lempel_ziv(binary_sequence))
@@ -1,32 +0,0 @@
1
- import numpy as np
2
- from scipy.spatial.distance import pdist, squareform
3
-
4
-
5
- def lyapunov_exponent(data, tau=1, m=2, dt=1):
6
- """
7
- Calculate the largest Lyapunov exponent for a time series.
8
-
9
- Args:
10
- data: numpy array or list of numerical values
11
- tau: time delay (default=1)
12
- m: embedding dimension (default=2)
13
- dt: time step (default=1)
14
-
15
- Returns:
16
- float: largest Lyapunov exponent
17
- """
18
- x = np.array(data)
19
- N = len(x)
20
- Y = np.zeros((N - (m - 1) * tau, m))
21
- for i in range(m):
22
- Y[:, i] = x[i * tau : N - (m - 1) * tau + i * tau]
23
- D = squareform(pdist(Y))
24
- np.fill_diagonal(D, np.inf) # Exclude self-matches
25
- nearest = np.argmin(D, axis=1)
26
- div = np.zeros(N - (m - 1) * tau - 1)
27
- for i in range(len(div)):
28
- div[i] = np.abs(x[i + 1] - x[nearest[i] + 1])
29
- positive_div = div[div > 0] # Avoid log(0)
30
- if len(positive_div) == 0:
31
- return 0.0
32
- return -np.mean(np.log(positive_div)) / dt
@@ -1,117 +0,0 @@
1
- #!/Users/donyin/miniconda3/envs/rotation-1/bin/python
2
-
3
- """
4
- - validating measures of complexity is actually working using synthetic data on various Hurst exponents
5
- """
6
-
7
- import numpy
8
- import matplotlib.pyplot as plt
9
- from complexeva.sequence.higuchi import hfd
10
- from complexeva.sequence.lempel_ziv import lempel_ziv
11
-
12
-
13
- class ComplexityMeasuresValidator2D:
14
- def __init__(self, funcs, timeseries_len=1000, num_hurst=200):
15
- self.funcs, self.num_hurst, self.timeseries_len = funcs, num_hurst, timeseries_len
16
-
17
- def fbm(self, hurst):
18
- """
19
- Generate fractional Brownian motion (fBm) time series.
20
-
21
- This method uses the spectral synthesis method to generate a fBm time series
22
- with a specified Hurst exponent.
23
-
24
- Parameters:
25
- -----------
26
- hurst : float
27
- The Hurst exponent, a value between 0 and 1 that characterizes
28
- the fractal properties of the time series.
29
-
30
- Returns:
31
- --------
32
- numpy.ndarray
33
- A 1D array representing the generated fBm time series.
34
-
35
- Notes:
36
- ------
37
- - The length of the generated time series is determined by self.timeseries_len.
38
- - This method uses the Fourier filtering method to generate fBm.
39
- - The first frequency component is set to a small non-zero value (1e-6) to avoid division by zero.
40
- """
41
-
42
- n = self.timeseries_len
43
- f = numpy.fft.fftfreq(n)
44
- f[0] = 1e-6 # avoid division by zero
45
- psd = numpy.abs(f) ** (-2 * hurst - 1)
46
- gaussian = numpy.random.randn(n) + 1j * numpy.random.randn(n)
47
- fBm = numpy.fft.ifft(gaussian * numpy.sqrt(psd)).real
48
- return fBm
49
-
50
- def plot_vs_hurst(self):
51
- hurst_exponent = numpy.linspace(0.001, 1, self.num_hurst)
52
- fBm_series = [self.fbm(h) for h in hurst_exponent]
53
- expected_hfd = [2 - h for h in hurst_exponent]
54
- hfd_results = []
55
-
56
- for func in self.funcs:
57
- hfd_results.append([func(series) for series in fBm_series])
58
-
59
- # ---- plotting ----
60
- fig, axs = plt.subplots(3, 1, figsize=(9, 10))
61
-
62
- axs[0].plot(fBm_series[0])
63
- axs[0].set_title(f"fBm series with H = {hurst_exponent[0]:.3f}", fontsize=21)
64
- mid_index = len(hurst_exponent) // 2
65
- axs[1].plot(fBm_series[mid_index])
66
- axs[1].set_title(f"fBm series with H = {hurst_exponent[mid_index]:.3f}", fontsize=21)
67
- axs[2].plot(fBm_series[-1])
68
- axs[2].set_title(f"fBm series with H = {hurst_exponent[-1]:.3f}", fontsize=21)
69
-
70
- for ax in axs:
71
- ax.set_xlabel("Time", fontsize=21)
72
- ax.set_ylabel("Value", fontsize=21)
73
- ax.tick_params(axis="both", which="major", labelsize=12)
74
- ax.grid(True)
75
-
76
- plt.tight_layout()
77
- plt.savefig("fBm_series_examples.png", dpi=320, bbox_inches="tight")
78
-
79
- # Create figure with two subplots side by side
80
- fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(18, 7))
81
-
82
- # Original HFD vs Hurst plot
83
- ax1.plot(hurst_exponent, expected_hfd, label="Expected FD")
84
- for i, func in enumerate(self.funcs):
85
- ax1.plot(hurst_exponent, hfd_results[i], label=f"HFD ({func.__name__})")
86
- ax1.set_xlabel("Hurst Exponent (H)", fontsize=14)
87
- ax1.set_ylabel("Raw Complexity", fontsize=14)
88
- ax1.set_title("Before Normalization", fontsize=16)
89
- ax1.grid(True)
90
- ax1.tick_params(axis="both", labelsize=12)
91
- ax1.legend(fontsize=12)
92
-
93
- # Normalized HFD vs Hurst plot
94
- ax2.plot(hurst_exponent, expected_hfd, label="Expected FD")
95
- for i, func in enumerate(self.funcs):
96
- # Normalize results between 0 and 1
97
- normalized_results = (hfd_results[i] - numpy.min(hfd_results[i])) / (
98
- numpy.max(hfd_results[i]) - numpy.min(hfd_results[i])
99
- )
100
- ax2.plot(hurst_exponent, normalized_results, label=f"HFD ({func.__name__})")
101
- ax2.set_xlabel("Hurst Exponent (H)", fontsize=14)
102
- ax2.set_ylabel("Normalized Complexity", fontsize=14)
103
- ax2.set_title("After Normalization (0-1)", fontsize=16)
104
- ax2.grid(True)
105
- ax2.tick_params(axis="both", labelsize=12)
106
- ax2.legend(fontsize=12)
107
-
108
- plt.tight_layout()
109
- plt.savefig("hfd_vs_hurst_comparison.png", dpi=320, bbox_inches="tight")
110
-
111
-
112
- if __name__ == "__main__":
113
- validator = ComplexityMeasuresValidator2D([hfd])
114
- validator.plot_vs_hurst()
115
-
116
- validator = ComplexityMeasuresValidator2D([lempel_ziv])
117
- validator.plot_vs_hurst()
@@ -1,28 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: complexeva
3
- Version: 0.2.2
4
- Home-page:
5
- Author: Don Yin
6
- Author-email: don_yin@outlook.com
7
- Description-Content-Type: text/markdown
8
- Dynamic: author
9
- Dynamic: author-email
10
- Dynamic: description
11
- Dynamic: description-content-type
12
-
13
- # overview
14
- implementations of complexity measures in 1D sequences
15
-
16
- # examples
17
- ```python
18
- from complexeva.sequence import lyapunov_exponent, hfd, lempel_ziv, compression_complexity
19
-
20
- signal = numpy.random.rand(1000) # shape: (1000,)
21
- complexity = hfd(signal)
22
- print(complexity)
23
- ```
24
-
25
- # install
26
- ```bash
27
- pip install complexeva
28
- ```
@@ -1,13 +0,0 @@
1
- README.md
2
- setup.py
3
- complexeva/__init__.py
4
- complexeva/validate.py
5
- complexeva.egg-info/PKG-INFO
6
- complexeva.egg-info/SOURCES.txt
7
- complexeva.egg-info/dependency_links.txt
8
- complexeva.egg-info/top_level.txt
9
- complexeva/sequence/__init__.py
10
- complexeva/sequence/compression.py
11
- complexeva/sequence/higuchi.py
12
- complexeva/sequence/lempel_ziv.py
13
- complexeva/sequence/lyapunov_exponent.py
@@ -1 +0,0 @@
1
- complexeva
@@ -1,4 +0,0 @@
1
- [egg_info]
2
- tag_build =
3
- tag_date = 0
4
-
complexeva-0.2.2/setup.py DELETED
@@ -1,15 +0,0 @@
1
- from setuptools import setup, find_packages
2
-
3
- setup(
4
- name="complexeva",
5
- version="0.2.2",
6
- packages=find_packages(),
7
- install_requires=[],
8
- author="Don Yin",
9
- author_email="don_yin@outlook.com",
10
- description="",
11
- long_description=open("README.md").read(),
12
- long_description_content_type="text/markdown",
13
- url="",
14
- pyhon_requires=">=3.12",
15
- )