entroscope 0.1.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.
Files changed (33) hide show
  1. entroscope-0.1.0/PKG-INFO +83 -0
  2. entroscope-0.1.0/README.md +64 -0
  3. entroscope-0.1.0/entroscope/__init__.py +16 -0
  4. entroscope-0.1.0/entroscope/_core.py +81 -0
  5. entroscope-0.1.0/entroscope/approximate.py +51 -0
  6. entroscope-0.1.0/entroscope/differential.py +43 -0
  7. entroscope-0.1.0/entroscope/multiscale.py +45 -0
  8. entroscope-0.1.0/entroscope/permutation.py +67 -0
  9. entroscope-0.1.0/entroscope/sample.py +54 -0
  10. entroscope-0.1.0/entroscope/shannon.py +53 -0
  11. entroscope-0.1.0/entroscope/spectral.py +52 -0
  12. entroscope-0.1.0/entroscope/utils/__init__.py +0 -0
  13. entroscope-0.1.0/entroscope/utils/normalize.py +13 -0
  14. entroscope-0.1.0/entroscope/utils/plot.py +88 -0
  15. entroscope-0.1.0/entroscope/utils/windows.py +19 -0
  16. entroscope-0.1.0/entroscope.egg-info/PKG-INFO +83 -0
  17. entroscope-0.1.0/entroscope.egg-info/SOURCES.txt +31 -0
  18. entroscope-0.1.0/entroscope.egg-info/dependency_links.txt +1 -0
  19. entroscope-0.1.0/entroscope.egg-info/requires.txt +9 -0
  20. entroscope-0.1.0/entroscope.egg-info/top_level.txt +1 -0
  21. entroscope-0.1.0/pyproject.toml +34 -0
  22. entroscope-0.1.0/setup.cfg +4 -0
  23. entroscope-0.1.0/tests/test_approximate.py +33 -0
  24. entroscope-0.1.0/tests/test_consistency.py +491 -0
  25. entroscope-0.1.0/tests/test_core.py +99 -0
  26. entroscope-0.1.0/tests/test_differential.py +42 -0
  27. entroscope-0.1.0/tests/test_examples.py +51 -0
  28. entroscope-0.1.0/tests/test_multiscale.py +37 -0
  29. entroscope-0.1.0/tests/test_permutation.py +49 -0
  30. entroscope-0.1.0/tests/test_plot.py +42 -0
  31. entroscope-0.1.0/tests/test_sample.py +37 -0
  32. entroscope-0.1.0/tests/test_shannon.py +44 -0
  33. entroscope-0.1.0/tests/test_spectral.py +32 -0
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: entroscope
3
+ Version: 0.1.0
4
+ Summary: The definitive entropy toolkit for time series data
5
+ Author: entroscope contributors
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/entroscope/entroscope
8
+ Keywords: entropy,time-series,shannon,permutation,spectral
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: numpy
12
+ Requires-Dist: pandas
13
+ Requires-Dist: scipy
14
+ Requires-Dist: matplotlib
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest; extra == "dev"
17
+ Requires-Dist: pytest-cov; extra == "dev"
18
+ Requires-Dist: ruff; extra == "dev"
19
+
20
+ # entroscope
21
+
22
+ [![CI](https://github.com/entroscope/entroscope/actions/workflows/ci.yml/badge.svg)](https://github.com/entroscope/entroscope/actions/workflows/ci.yml)
23
+
24
+ **The definitive entropy toolkit for time series data.**
25
+
26
+ `pip install entroscope` and get every entropy measure you'd ever need, with one
27
+ consistent interface that works directly on pandas Series and numpy arrays.
28
+
29
+ Born from [NextOnMenu](https://nextonmenu.com), where Shannon entropy of food-trend
30
+ search interest had to be computed by hand. entroscope makes that a one-liner.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install entroscope
36
+ ```
37
+
38
+ ## Quick start
39
+
40
+ ```python
41
+ import pandas as pd
42
+ from entroscope import shannon
43
+
44
+ s = pd.Series([10, 20, 15, 80, 90, 85, 88, 92])
45
+ shannon.compute(s) # single entropy value
46
+ shannon.rolling(s, window=20) # rolling entropy over time
47
+ shannon.delta(s, window=20) # rate of change
48
+ shannon.plot(s, window=20) # matplotlib Figure
49
+ ```
50
+
51
+ ## Measures
52
+
53
+ shannon · permutation · sample · approximate · spectral · differential · multiscale
54
+
55
+ Every measure shares the same API: `compute`, `rolling`, `delta`, `plot`
56
+ (`normalized` where a theoretical maximum exists). Series in → Series out
57
+ (index preserved); ndarray in → ndarray out.
58
+
59
+ | Method | Returns |
60
+ | ------------- | ------------------------------------ |
61
+ | `compute` | `float` |
62
+ | `rolling` | Series/ndarray, same length |
63
+ | `delta` | Series/ndarray (first difference) |
64
+ | `normalized` | `float` in [0, 1] (where defined) |
65
+ | `plot` | `matplotlib.figure.Figure` |
66
+
67
+ ## Real-world example — food-trend analysis (NextOnMenu)
68
+
69
+ ```python
70
+ import pandas as pd
71
+ from entroscope import shannon
72
+
73
+ matcha_trends = pd.read_csv("matcha_trends.csv")["interest"]
74
+ shannon.plot(matcha_trends, window=20, title="Matcha — entropy over time")
75
+ # entropy drops before a trend goes mainstream
76
+ ```
77
+
78
+ A sustained drop in rolling Shannon entropy means search interest is becoming
79
+ concentrated/structured rather than noisy — an early signal of a trend.
80
+
81
+ ## License
82
+
83
+ MIT
@@ -0,0 +1,64 @@
1
+ # entroscope
2
+
3
+ [![CI](https://github.com/entroscope/entroscope/actions/workflows/ci.yml/badge.svg)](https://github.com/entroscope/entroscope/actions/workflows/ci.yml)
4
+
5
+ **The definitive entropy toolkit for time series data.**
6
+
7
+ `pip install entroscope` and get every entropy measure you'd ever need, with one
8
+ consistent interface that works directly on pandas Series and numpy arrays.
9
+
10
+ Born from [NextOnMenu](https://nextonmenu.com), where Shannon entropy of food-trend
11
+ search interest had to be computed by hand. entroscope makes that a one-liner.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install entroscope
17
+ ```
18
+
19
+ ## Quick start
20
+
21
+ ```python
22
+ import pandas as pd
23
+ from entroscope import shannon
24
+
25
+ s = pd.Series([10, 20, 15, 80, 90, 85, 88, 92])
26
+ shannon.compute(s) # single entropy value
27
+ shannon.rolling(s, window=20) # rolling entropy over time
28
+ shannon.delta(s, window=20) # rate of change
29
+ shannon.plot(s, window=20) # matplotlib Figure
30
+ ```
31
+
32
+ ## Measures
33
+
34
+ shannon · permutation · sample · approximate · spectral · differential · multiscale
35
+
36
+ Every measure shares the same API: `compute`, `rolling`, `delta`, `plot`
37
+ (`normalized` where a theoretical maximum exists). Series in → Series out
38
+ (index preserved); ndarray in → ndarray out.
39
+
40
+ | Method | Returns |
41
+ | ------------- | ------------------------------------ |
42
+ | `compute` | `float` |
43
+ | `rolling` | Series/ndarray, same length |
44
+ | `delta` | Series/ndarray (first difference) |
45
+ | `normalized` | `float` in [0, 1] (where defined) |
46
+ | `plot` | `matplotlib.figure.Figure` |
47
+
48
+ ## Real-world example — food-trend analysis (NextOnMenu)
49
+
50
+ ```python
51
+ import pandas as pd
52
+ from entroscope import shannon
53
+
54
+ matcha_trends = pd.read_csv("matcha_trends.csv")["interest"]
55
+ shannon.plot(matcha_trends, window=20, title="Matcha — entropy over time")
56
+ # entropy drops before a trend goes mainstream
57
+ ```
58
+
59
+ A sustained drop in rolling Shannon entropy means search interest is becoming
60
+ concentrated/structured rather than noisy — an early signal of a trend.
61
+
62
+ ## License
63
+
64
+ MIT
@@ -0,0 +1,16 @@
1
+ """entroscope — the definitive entropy toolkit for time series data."""
2
+
3
+ from . import shannon, permutation, spectral, sample, approximate, differential, multiscale
4
+ from .utils import plot
5
+
6
+ __version__ = "0.1.0"
7
+ __all__ = [
8
+ "shannon",
9
+ "permutation",
10
+ "spectral",
11
+ "sample",
12
+ "approximate",
13
+ "differential",
14
+ "multiscale",
15
+ "plot",
16
+ ]
@@ -0,0 +1,81 @@
1
+ """Core engine: input coercion, output wrapping, rolling/delta drivers, plotting.
2
+
3
+ Every measure module delegates its standard methods here so the
4
+ "Series in -> Series out, array in -> array out" contract and the windowing
5
+ logic live in exactly one place.
6
+ """
7
+
8
+ import matplotlib
9
+
10
+ matplotlib.use("Agg") # headless-safe; never opens a window
11
+ import matplotlib.pyplot as plt # noqa: E402
12
+ import numpy as np # noqa: E402
13
+ import pandas as pd # noqa: E402
14
+
15
+ from .utils.windows import sliding_windows # noqa: E402
16
+
17
+
18
+ def as_array(x):
19
+ """Coerce input to a 1-D float ndarray, returning (array, index_or_None)."""
20
+ if isinstance(x, pd.Series):
21
+ index = x.index
22
+ arr = x.to_numpy(dtype=float)
23
+ else:
24
+ index = None
25
+ arr = np.asarray(x, dtype=float)
26
+ if arr.ndim != 1:
27
+ raise ValueError("input must be 1-dimensional")
28
+ if arr.size == 0:
29
+ raise ValueError("input is empty")
30
+ return arr, index
31
+
32
+
33
+ def wrap(values, index):
34
+ """Wrap a result array as a Series (if index given) or return the ndarray."""
35
+ values = np.asarray(values, dtype=float)
36
+ if index is not None:
37
+ return pd.Series(values, index=index)
38
+ return values
39
+
40
+
41
+ def rolling(x, window, kernel, **params):
42
+ """Apply `kernel` over each full sliding window.
43
+
44
+ Output has the same length as the input; positions before the first full
45
+ window are NaN. Returns a Series (preserving index) if `x` was a Series.
46
+ """
47
+ arr, index = as_array(x)
48
+ n = len(arr)
49
+ if window <= 0:
50
+ raise ValueError("window must be a positive integer")
51
+ if window > n:
52
+ raise ValueError(f"window ({window}) is larger than series length ({n})")
53
+ out = np.full(n, np.nan)
54
+ for end, w in zip(range(window, n + 1), sliding_windows(arr, window)):
55
+ out[end - 1] = kernel(w, **params)
56
+ return wrap(out, index)
57
+
58
+
59
+ def delta(x, window, kernel, **params):
60
+ """First difference of the rolling entropy."""
61
+ roll = rolling(x, window, kernel, **params)
62
+ if isinstance(roll, pd.Series):
63
+ return roll.diff()
64
+ out = np.full_like(roll, np.nan)
65
+ out[1:] = np.diff(roll)
66
+ return out
67
+
68
+
69
+ def make_plot(x, window, kernel, *, title=None, ylabel="entropy", **params):
70
+ """Build and return a Figure of rolling entropy. Never calls plt.show()."""
71
+ roll = rolling(x, window, kernel, **params)
72
+ fig, ax = plt.subplots(figsize=(10, 4))
73
+ if isinstance(roll, pd.Series):
74
+ ax.plot(roll.index, roll.to_numpy())
75
+ else:
76
+ ax.plot(range(len(roll)), roll)
77
+ ax.set_title(title or f"Rolling {ylabel} (window={window})")
78
+ ax.set_xlabel("position")
79
+ ax.set_ylabel(ylabel)
80
+ fig.tight_layout()
81
+ return fig
@@ -0,0 +1,51 @@
1
+ """Approximate entropy (ApEn) — regularity measure, less noise-sensitive."""
2
+
3
+ import numpy as np
4
+
5
+ from . import _core
6
+
7
+
8
+ def _phi(values, m, tol):
9
+ """Mean of log(fraction of templates within `tol`) over length-m templates."""
10
+ n = len(values)
11
+ templates = np.array([values[i : i + m] for i in range(n - m + 1)])
12
+ counts = np.empty(len(templates))
13
+ for i in range(len(templates)):
14
+ dist = np.max(np.abs(templates - templates[i]), axis=1)
15
+ counts[i] = np.count_nonzero(dist <= tol) / len(templates)
16
+ return float(np.mean(np.log(counts)))
17
+
18
+
19
+ def _kernel(values, m=2, r=0.2):
20
+ """Approximate entropy: phi(m) - phi(m+1)."""
21
+ if r <= 0:
22
+ raise ValueError("r must be positive")
23
+ if m < 1:
24
+ raise ValueError("m must be >= 1")
25
+ values = np.asarray(values, dtype=float)
26
+ n = len(values)
27
+ if n <= m + 1:
28
+ raise ValueError("series too short for given m")
29
+ tol = r * np.std(values)
30
+ if tol == 0:
31
+ return 0.0
32
+ return float(_phi(values, m, tol) - _phi(values, m + 1, tol))
33
+
34
+
35
+ def compute(series, m=2, r=0.2):
36
+ arr, _ = _core.as_array(series)
37
+ return _kernel(arr, m=m, r=r)
38
+
39
+
40
+ def rolling(series, window=50, m=2, r=0.2):
41
+ return _core.rolling(series, window, _kernel, m=m, r=r)
42
+
43
+
44
+ def delta(series, window=50, m=2, r=0.2):
45
+ return _core.delta(series, window, _kernel, m=m, r=r)
46
+
47
+
48
+ def plot(series, window=50, m=2, r=0.2, title=None):
49
+ return _core.make_plot(
50
+ series, window, _kernel, m=m, r=r, title=title, ylabel="approximate entropy"
51
+ )
@@ -0,0 +1,43 @@
1
+ """Differential entropy — continuous entropy via a fitted distribution."""
2
+
3
+ import numpy as np
4
+ from scipy import stats
5
+
6
+ from . import _core
7
+
8
+
9
+ def _kernel(values, dist="normal"):
10
+ """Differential entropy (nats). dist in {'normal', 'kde'}."""
11
+ values = np.asarray(values, dtype=float)
12
+ if dist == "normal":
13
+ var = np.var(values)
14
+ if var == 0:
15
+ return float("-inf") # degenerate: zero-width distribution
16
+ return float(0.5 * np.log(2 * np.pi * np.e * var))
17
+ if dist == "kde":
18
+ if np.std(values) == 0:
19
+ return float("-inf")
20
+ kde = stats.gaussian_kde(values)
21
+ density = kde(values)
22
+ density = density[density > 0]
23
+ return float(-np.mean(np.log(density)))
24
+ raise ValueError(f"unknown dist {dist!r}; expected 'normal' or 'kde'")
25
+
26
+
27
+ def compute(series, dist="normal"):
28
+ arr, _ = _core.as_array(series)
29
+ return _kernel(arr, dist=dist)
30
+
31
+
32
+ def rolling(series, window=50, dist="kde"):
33
+ return _core.rolling(series, window, _kernel, dist=dist)
34
+
35
+
36
+ def delta(series, window=50, dist="kde"):
37
+ return _core.delta(series, window, _kernel, dist=dist)
38
+
39
+
40
+ def plot(series, window=50, dist="kde", title=None):
41
+ return _core.make_plot(
42
+ series, window, _kernel, dist=dist, title=title, ylabel="differential entropy"
43
+ )
@@ -0,0 +1,45 @@
1
+ """Multiscale entropy — sample entropy across coarse-grained time scales."""
2
+
3
+ import matplotlib.pyplot as plt
4
+
5
+ from . import _core, sample
6
+
7
+
8
+ def _coarse_grain(values, scale):
9
+ """Average non-overlapping blocks of length `scale`."""
10
+ n = len(values) // scale
11
+ trimmed = values[: n * scale]
12
+ return trimmed.reshape(n, scale).mean(axis=1)
13
+
14
+
15
+ def compute(series, scales=range(1, 10), method="sample"):
16
+ """Return {scale: entropy} by coarse-graining then applying `method`.
17
+
18
+ Scales that coarse-grain the series below sample entropy's minimum
19
+ length are skipped (omitted from the result).
20
+ """
21
+ if method != "sample":
22
+ raise ValueError("only method='sample' is supported")
23
+ arr, _ = _core.as_array(series)
24
+ result = {}
25
+ for scale in scales:
26
+ if scale == 1:
27
+ grained = arr
28
+ else:
29
+ grained = _coarse_grain(arr, scale)
30
+ if len(grained) < 4: # sample entropy needs n > m+1 (m=2 default)
31
+ continue
32
+ result[int(scale)] = sample.compute(grained)
33
+ return result
34
+
35
+
36
+ def plot(series, scales=range(1, 10), title=None):
37
+ """Plot the complexity profile (entropy vs. scale)."""
38
+ profile = compute(series, scales=scales)
39
+ fig, ax = plt.subplots(figsize=(8, 4))
40
+ ax.plot(list(profile.keys()), list(profile.values()), marker="o")
41
+ ax.set_title(title or "Multiscale entropy")
42
+ ax.set_xlabel("scale")
43
+ ax.set_ylabel("sample entropy")
44
+ fig.tight_layout()
45
+ return fig
@@ -0,0 +1,67 @@
1
+ """Permutation entropy — complexity from ordinal patterns (Bandt & Pompe)."""
2
+
3
+ import math
4
+ from itertools import permutations
5
+
6
+ import numpy as np
7
+
8
+ from . import _core
9
+ from .utils import normalize
10
+
11
+
12
+ def _kernel(values, order=3, delay=1):
13
+ """Permutation entropy (base 2) of ordinal patterns of length `order`."""
14
+ if order < 2:
15
+ raise ValueError("order must be >= 2")
16
+ if delay < 1:
17
+ raise ValueError("delay must be >= 1")
18
+ values = np.asarray(values, dtype=float)
19
+ n = len(values)
20
+ span = delay * (order - 1)
21
+ if n - span <= 0:
22
+ raise ValueError("series too short for given order/delay")
23
+ perm_index = {p: i for i, p in enumerate(permutations(range(order)))}
24
+ counts = np.zeros(len(perm_index))
25
+ for i in range(n - span):
26
+ window = values[i : i + span + 1 : delay]
27
+ pattern = tuple(np.argsort(window, kind="stable"))
28
+ counts[perm_index[pattern]] += 1
29
+ total = counts.sum()
30
+ p = counts[counts > 0] / total
31
+ return float(-np.sum(p * np.log2(p)))
32
+
33
+
34
+ def _check_window(window, order, delay):
35
+ """Raise ValueError if window is too small for the given order/delay."""
36
+ span = delay * (order - 1)
37
+ if window <= span:
38
+ raise ValueError(f"window ({window}) must exceed delay*(order-1) = {span}")
39
+
40
+
41
+ def compute(series, order=3, delay=1):
42
+ arr, _ = _core.as_array(series)
43
+ return _kernel(arr, order=order, delay=delay)
44
+
45
+
46
+ def rolling(series, window=20, order=3, delay=1):
47
+ _check_window(window, order, delay)
48
+ return _core.rolling(series, window, _kernel, order=order, delay=delay)
49
+
50
+
51
+ def delta(series, window=20, order=3, delay=1):
52
+ _check_window(window, order, delay)
53
+ return _core.delta(series, window, _kernel, order=order, delay=delay)
54
+
55
+
56
+ def normalized(series, order=3, delay=1):
57
+ """Entropy scaled to [0, 1] by log2(order!)."""
58
+ return normalize.by_max(
59
+ compute(series, order=order, delay=delay), math.log2(math.factorial(order))
60
+ )
61
+
62
+
63
+ def plot(series, window=20, order=3, delay=1, title=None):
64
+ _check_window(window, order, delay)
65
+ return _core.make_plot(
66
+ series, window, _kernel, order=order, delay=delay, title=title, ylabel="permutation entropy"
67
+ )
@@ -0,0 +1,54 @@
1
+ """Sample entropy (SampEn) — regularity/predictability of a time series."""
2
+
3
+ import numpy as np
4
+
5
+ from . import _core
6
+
7
+
8
+ def _count_matches(values, m, tol):
9
+ """Count template-vector pairs (length m) within Chebyshev distance `tol`."""
10
+ n = len(values)
11
+ templates = np.array([values[i : i + m] for i in range(n - m + 1)])
12
+ count = 0
13
+ for i in range(len(templates) - 1):
14
+ dist = np.max(np.abs(templates[i + 1 :] - templates[i]), axis=1)
15
+ count += np.count_nonzero(dist <= tol)
16
+ return count
17
+
18
+
19
+ def _kernel(values, m=2, r=0.2):
20
+ """Sample entropy: -ln(A/B) of length-(m+1) vs length-m matches."""
21
+ if r <= 0:
22
+ raise ValueError("r must be positive")
23
+ if m < 1:
24
+ raise ValueError("m must be >= 1")
25
+ values = np.asarray(values, dtype=float)
26
+ n = len(values)
27
+ if n <= m + 1:
28
+ raise ValueError("series too short for given m")
29
+ tol = r * np.std(values)
30
+ if tol == 0:
31
+ return 0.0 # constant signal: perfectly regular
32
+ b = _count_matches(values, m, tol)
33
+ a = _count_matches(values, m + 1, tol)
34
+ if b == 0 or a == 0:
35
+ # no regularity detected; return a large-but-finite ceiling
36
+ return float(np.log((n - m) * (n - m - 1)))
37
+ return float(-np.log(a / b))
38
+
39
+
40
+ def compute(series, m=2, r=0.2):
41
+ arr, _ = _core.as_array(series)
42
+ return _kernel(arr, m=m, r=r)
43
+
44
+
45
+ def rolling(series, window=50, m=2, r=0.2):
46
+ return _core.rolling(series, window, _kernel, m=m, r=r)
47
+
48
+
49
+ def delta(series, window=50, m=2, r=0.2):
50
+ return _core.delta(series, window, _kernel, m=m, r=r)
51
+
52
+
53
+ def plot(series, window=50, m=2, r=0.2, title=None):
54
+ return _core.make_plot(series, window, _kernel, m=m, r=r, title=title, ylabel="sample entropy")
@@ -0,0 +1,53 @@
1
+ """Shannon entropy — classic information entropy over a binned distribution."""
2
+
3
+ import numpy as np
4
+
5
+ from . import _core
6
+ from .utils import normalize
7
+
8
+
9
+ def _kernel(values, bins=10):
10
+ """Shannon entropy (base 2) of `values` histogrammed into `bins`."""
11
+ if bins <= 0:
12
+ raise ValueError("bins must be a positive integer")
13
+ values = np.asarray(values, dtype=float)
14
+ counts, _ = np.histogram(values, bins=bins)
15
+ total = counts.sum()
16
+ if total == 0:
17
+ return 0.0
18
+ p = counts[counts > 0] / total
19
+ return float(-np.sum(p * np.log2(p)))
20
+
21
+
22
+ def compute(series, bins=10):
23
+ arr, _ = _core.as_array(series)
24
+ return _kernel(arr, bins=bins)
25
+
26
+
27
+ def rolling(series, window=20, bins=10):
28
+ return _core.rolling(series, window, _kernel, bins=bins)
29
+
30
+
31
+ def delta(series, window=20, bins=10):
32
+ return _core.delta(series, window, _kernel, bins=bins)
33
+
34
+
35
+ def normalized(series, bins=10):
36
+ """Entropy scaled to [0, 1] by the maximum possible (log2(bins))."""
37
+ return normalize.by_max(compute(series, bins=bins), np.log2(bins))
38
+
39
+
40
+ def geographic(region_df, col="interest"):
41
+ """Shannon entropy of a spatial distribution (e.g. Google Trends by region)."""
42
+ values = np.asarray(region_df[col], dtype=float)
43
+ total = values.sum()
44
+ if total == 0:
45
+ return 0.0
46
+ p = values[values > 0] / total
47
+ return float(-np.sum(p * np.log2(p)))
48
+
49
+
50
+ def plot(series, window=20, bins=10, title=None):
51
+ return _core.make_plot(
52
+ series, window, _kernel, bins=bins, title=title, ylabel="Shannon entropy"
53
+ )
@@ -0,0 +1,52 @@
1
+ """Spectral entropy — Shannon entropy of the normalized power spectrum."""
2
+
3
+ import numpy as np
4
+ from scipy import signal as _signal
5
+
6
+ from . import _core
7
+ from .utils import normalize
8
+
9
+
10
+ def _psd(values, sf):
11
+ """Return the (positive-frequency) power spectral density of `values`."""
12
+ values = np.asarray(values, dtype=float)
13
+ values = values - values.mean()
14
+ freqs, psd = _signal.periodogram(values, fs=sf)
15
+ return freqs, psd
16
+
17
+
18
+ def _kernel(values, sf=1.0):
19
+ """Spectral entropy (base 2) of the normalized power spectrum."""
20
+ _, psd = _psd(values, sf)
21
+ total = psd.sum()
22
+ if total == 0:
23
+ return 0.0
24
+ p = psd[psd > 0] / total
25
+ return float(-np.sum(p * np.log2(p)))
26
+
27
+
28
+ def compute(series, sf=1.0):
29
+ arr, _ = _core.as_array(series)
30
+ return _kernel(arr, sf=sf)
31
+
32
+
33
+ def rolling(series, window=50, sf=1.0):
34
+ return _core.rolling(series, window, _kernel, sf=sf)
35
+
36
+
37
+ def delta(series, window=50, sf=1.0):
38
+ return _core.delta(series, window, _kernel, sf=sf)
39
+
40
+
41
+ def normalized(series, sf=1.0):
42
+ """Entropy scaled to [0, 1] by log2(number of frequency bins)."""
43
+ arr, _ = _core.as_array(series)
44
+ freqs, _psd_vals = _psd(arr, sf)
45
+ n_bins = int(np.count_nonzero(_psd_vals > 0))
46
+ if n_bins <= 1:
47
+ return 0.0
48
+ return normalize.by_max(_kernel(arr, sf=sf), np.log2(n_bins))
49
+
50
+
51
+ def plot(series, window=50, sf=1.0, title=None):
52
+ return _core.make_plot(series, window, _kernel, sf=sf, title=title, ylabel="spectral entropy")
File without changes
@@ -0,0 +1,13 @@
1
+ """Normalization helpers: scale an entropy value into [0, 1]."""
2
+
3
+
4
+ def by_max(value, max_value):
5
+ """Return value / max_value clipped to [0, 1]; 0.0 if max_value == 0."""
6
+ if max_value == 0:
7
+ return 0.0
8
+ ratio = value / max_value
9
+ if ratio < 0.0:
10
+ return 0.0
11
+ if ratio > 1.0:
12
+ return 1.0
13
+ return float(ratio)