Skip to content

Commit a63140a

Browse files
Par-pythonclaude
andcommitted
Return NaN for non-finite input instead of a made-up entropy
One NaN or inf in the input used to produce a plausible-looking number: permutation.compute counted NaN as an ordinal pattern (argsort ranks it last), spectral.compute returned -0.0 because a NaN spectrum filters to nothing, sample.compute returned a large finite value, and shannon.compute raised. rolling inherited this, so one gap silently corrupted every window that contained it. Every measure's kernel is now wrapped by _core.nan_on_non_finite, so compute, normalized, rolling and delta return NaN for exactly the windows that hold a non-finite value and leave the rest of the series unchanged. multiscale, transfer, spectral.normalized and divergence.kl/js get the same guard. Zero entropy is also returned as 0.0, never -0.0. Found while building the training early-warning study (github.com/Par-python/training-early-warning). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 2461fef commit a63140a

13 files changed

Lines changed: 184 additions & 8 deletions

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
## Unreleased
44

5+
- **Fixed (changes results):** missing and infinite values no longer produce
6+
plausible-looking numbers. Previously one NaN made `permutation.compute`
7+
return a finite value, `spectral.compute` return `-0.0`, `sample.compute`
8+
return a large finite value, and `shannon.compute` raise. Now every measure
9+
(`compute`, `normalized`, `multiscale`, `transfer`, `divergence.kl`/`js`)
10+
returns NaN when its input holds NaN or ±inf, and `rolling`/`delta` are NaN
11+
for exactly the windows that contain one. Reported while building the
12+
training early-warning study.
13+
- **Fixed:** zero entropy is returned as `0.0`, never `-0.0` (constant or
14+
perfectly ordered input, a single region in `shannon.geographic`).
515
- CI: move the remaining GitHub Actions off the deprecated Node 20 runtime
616
(`upload-artifact` and `download-artifact` to v7, `upload-pages-artifact` and
717
`deploy-pages` to v5).

‎README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ Every measure exposes the same methods, so switching measures is a one-word chan
8383
Shannon additionally provides `geographic(df, col=...)` for spatial distributions
8484
(e.g. search interest by region). Multiscale provides `compute` and `plot`.
8585

86+
**Missing values.** Any NaN or ±inf in the input makes the result NaN, never a
87+
made-up number. `rolling` and `delta` are NaN only for the windows that contain
88+
the gap, so the rest of the series is unaffected. `rolling` is causal: the value
89+
at position t uses positions t−window+1 through t.
90+
8691
The two-input measures take a pair of series. `transfer.compute(x, y)` (plus
8792
`rolling`, `delta`, `plot`) estimates how much `x`'s past tells you about `y`'s
8893
future; `divergence.kl(p, q)` and `divergence.js(p, q)` (plus `plot`) compare two

‎entroscope/_core.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
non-interactive backend should set ``MPLBACKEND=Agg`` in their environment.
1111
"""
1212

13+
import functools
14+
1315
import matplotlib.pyplot as plt
1416
import numpy as np
1517
import pandas as pd
@@ -30,6 +32,23 @@ def _is_polars_series(x):
3032
return cls.__name__ == "Series" and cls.__module__.startswith("polars")
3133

3234

35+
def nan_on_non_finite(kernel):
36+
"""Make `kernel` return NaN when its input contains NaN or +/-inf.
37+
38+
Without this, kernels turn missing values into plausible-looking numbers (argsort
39+
ranks NaN last, a NaN spectrum sums to NaN, ...). Applied to each measure's
40+
`_kernel`, it also makes `rolling` NaN for exactly the windows that hold a gap.
41+
"""
42+
43+
@functools.wraps(kernel)
44+
def wrapper(values, *args, **kwargs):
45+
if not np.isfinite(np.asarray(values, dtype=float)).all():
46+
return float("nan")
47+
return kernel(values, *args, **kwargs)
48+
49+
return wrapper
50+
51+
3352
def as_array(x):
3453
"""Coerce input to a 1-D float ndarray, returning (array, index_or_None).
3554

‎entroscope/approximate.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ def _phi(values, m, tol):
1616
return float(np.mean(np.log(counts)))
1717

1818

19+
@_core.nan_on_non_finite
1920
def _kernel(values, m=2, r=0.2):
2021
"""Approximate entropy: phi(m) - phi(m+1)."""
2122
if r <= 0:

‎entroscope/differential.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from . import _core
77

88

9+
@_core.nan_on_non_finite
910
def _kernel(values, dist="normal"):
1011
"""Differential entropy (nats). dist in {'normal', 'kde'}."""
1112
values = np.asarray(values, dtype=float)

‎entroscope/divergence.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ def _binned_probs(p, q, bins):
4040
_LN2 = np.log(2.0)
4141

4242

43+
def _all_finite(*arrays):
44+
"""False if any sample holds NaN or +/-inf; kl/js then return NaN."""
45+
return all(np.isfinite(a).all() for a in arrays)
46+
47+
4348
def _kl_bits(pp, qq):
4449
"""KL(pp || qq) in bits, with epsilon smoothing so the result stays finite."""
4550
pp = pp + _EPS
@@ -57,6 +62,8 @@ def kl(p, q, bins=10):
5762
"""
5863
pa, _ = _core.as_array(p)
5964
qa, _ = _core.as_array(q)
65+
if not _all_finite(pa, qa):
66+
return float("nan")
6067
pp, qq = _binned_probs(pa, qa, bins)
6168
return _kl_bits(pp, qq)
6269

@@ -68,6 +75,8 @@ def js(p, q, bins=10):
6875
"""
6976
pa, _ = _core.as_array(p)
7077
qa, _ = _core.as_array(q)
78+
if not _all_finite(pa, qa):
79+
return float("nan")
7180
pp, qq = _binned_probs(pa, qa, bins)
7281
m = 0.5 * (pp + qq)
7382
return 0.5 * _kl_bits(pp, m) + 0.5 * _kl_bits(qq, m)

‎entroscope/multiscale.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def compute(series, scales=range(1, 10), method="sample", m=2, r=0.2):
3030
if m < 1:
3131
raise ValueError("m must be >= 1")
3232
arr, _ = _core.as_array(series)
33+
finite = bool(np.isfinite(arr).all())
3334
tol = r * np.std(arr)
3435
result = {}
3536
for scale in scales:
@@ -39,7 +40,7 @@ def compute(series, scales=range(1, 10), method="sample", m=2, r=0.2):
3940
grained = _coarse_grain(arr, scale)
4041
if len(grained) <= m + 1: # sample entropy needs n > m+1
4142
continue
42-
result[int(scale)] = sample._sampen(grained, m, tol)
43+
result[int(scale)] = sample._sampen(grained, m, tol) if finite else float("nan")
4344
return result
4445

4546

‎entroscope/permutation.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from .utils import normalize
1010

1111

12+
@_core.nan_on_non_finite
1213
def _kernel(values, order=3, delay=1):
1314
"""Permutation entropy (base 2) of ordinal patterns of length `order`."""
1415
if order < 2:
@@ -28,7 +29,7 @@ def _kernel(values, order=3, delay=1):
2829
counts[perm_index[pattern]] += 1
2930
total = counts.sum()
3031
p = counts[counts > 0] / total
31-
return float(-np.sum(p * np.log2(p)))
32+
return float(-np.sum(p * np.log2(p))) + 0.0 # + 0.0 turns -0.0 into 0.0
3233

3334

3435
def _check_window(window, order, delay):

‎entroscope/sample.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def _sampen(values, m, tol):
3232
return float(-np.log(a / b))
3333

3434

35+
@_core.nan_on_non_finite
3536
def _kernel(values, m=2, r=0.2):
3637
"""Sample entropy: -ln(A/B) of length-(m+1) vs length-m matches."""
3738
if r <= 0:

‎entroscope/shannon.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from .utils import normalize
77

88

9+
@_core.nan_on_non_finite
910
def _kernel(values, bins=10):
1011
"""Shannon entropy (base 2) of `values` histogrammed into `bins`."""
1112
if bins <= 0:
@@ -16,7 +17,7 @@ def _kernel(values, bins=10):
1617
if total == 0:
1718
return 0.0
1819
p = counts[counts > 0] / total
19-
return float(-np.sum(p * np.log2(p)))
20+
return float(-np.sum(p * np.log2(p))) + 0.0 # + 0.0 turns -0.0 into 0.0
2021

2122

2223
def compute(series, bins=10):
@@ -44,7 +45,7 @@ def geographic(region_df, col="interest"):
4445
if total == 0:
4546
return 0.0
4647
p = values[values > 0] / total
47-
return float(-np.sum(p * np.log2(p)))
48+
return float(-np.sum(p * np.log2(p))) + 0.0 # + 0.0 turns -0.0 into 0.0
4849

4950

5051
def plot(series, window=20, bins=10, title=None):

0 commit comments

Comments
 (0)