Skip to content

Commit 461d5fa

Browse files
authored
Merge pull request #1 from Par-python/feat/transfer-entropy
Add transfer entropy (KSG + binned) — first bivariate information-flow measure
2 parents 16f69a1 + 1190cc3 commit 461d5fa

9 files changed

Lines changed: 699 additions & 2 deletions

File tree

entroscope/__init__.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
"""entroscope: the definitive entropy toolkit for time series data."""
22

3-
from . import shannon, permutation, spectral, sample, approximate, differential, multiscale
3+
from . import (
4+
shannon,
5+
permutation,
6+
spectral,
7+
sample,
8+
approximate,
9+
differential,
10+
multiscale,
11+
transfer,
12+
)
413
from .utils import plot
514

615
__version__ = "0.1.1"
@@ -12,5 +21,6 @@
1221
"approximate",
1322
"differential",
1423
"multiscale",
24+
"transfer",
1525
"plot",
1626
]

entroscope/_transfer_estimators.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Transfer-entropy estimators (history length 1) and shared delay-embedding.
2+
3+
Private module: all the math lives here so the public ``transfer`` module stays
4+
thin. Both estimators consume the SAME embedded vectors from ``embed`` — the
5+
embedding is verified in isolation (see tests) so a bug here cannot silently fool
6+
both estimators.
7+
8+
Correctness is established by three independent test authorities (see
9+
tests/test_transfer.py): the bivariate-Gaussian closed form (magnitude), the
10+
Kraskov-2004 analytic mutual information (KSG core), and a hand-checked isolated
11+
embedding test.
12+
"""
13+
14+
import numpy as np
15+
from scipy.special import digamma
16+
from .utils import knn
17+
18+
19+
def embed(x, y, lag=1):
20+
"""Build aligned (y_future, y_past, x_past) sample columns, history length 1.
21+
22+
For each valid time t (from ``lag`` to n-1): y_future=y[t], y_past=y[t-lag],
23+
x_past=x[t-lag]. Returns three 1-D arrays of length ``n - lag``.
24+
"""
25+
x = np.asarray(x, dtype=float)
26+
y = np.asarray(y, dtype=float)
27+
if lag < 1:
28+
raise ValueError("lag must be >= 1")
29+
n = len(y)
30+
if n - lag < 1:
31+
raise ValueError(f"series too short ({n}) for lag {lag}")
32+
y_future = y[lag:]
33+
y_past = y[: n - lag]
34+
x_past = x[: n - lag]
35+
return y_future, y_past, x_past
36+
37+
38+
def _hist_prob(*cols, bins):
39+
"""Joint probability table over the given equal-width-binned columns."""
40+
sample = np.column_stack(cols)
41+
counts, _ = np.histogramdd(sample, bins=bins)
42+
total = counts.sum()
43+
return counts / total if total > 0 else counts
44+
45+
46+
def te_binned(y_future, y_past, x_past, bins=6):
47+
"""Transfer entropy X->Y via equal-width histogram probabilities (base 2)."""
48+
p_fpx = _hist_prob(y_future, y_past, x_past, bins=bins) # p(yf, yp, xp)
49+
p_fp = p_fpx.sum(axis=2) # p(yf, yp)
50+
p_px = p_fpx.sum(axis=0) # p(yp, xp)
51+
p_p = p_fpx.sum(axis=(0, 2)) # p(yp)
52+
53+
te = 0.0
54+
nf, npq, nx = p_fpx.shape
55+
for i in range(nf):
56+
for j in range(npq):
57+
for k in range(nx):
58+
pijk = p_fpx[i, j, k]
59+
if pijk <= 0:
60+
continue
61+
denom = p_fp[i, j] * p_px[j, k]
62+
numer = pijk * p_p[j]
63+
if denom <= 0 or numer <= 0:
64+
continue
65+
te += pijk * np.log2(numer / denom)
66+
return float(te)
67+
68+
69+
_LN2 = np.log(2.0)
70+
71+
72+
def te_ksg(y_future, y_past, x_past, k=4):
73+
"""Transfer entropy X->Y via the KSG (Kraskov) k-NN estimator, in bits.
74+
75+
Joint space Z = (y_future, y_past, x_past). eps = Chebyshev distance to the
76+
k-th neighbor in Z; marginal neighbor counts within eps give the digamma
77+
terms. Returned in bits (nats / ln 2) to match the library's base-2 output.
78+
"""
79+
yf = np.asarray(y_future, dtype=float).reshape(-1, 1)
80+
yp = np.asarray(y_past, dtype=float).reshape(-1, 1)
81+
xp = np.asarray(x_past, dtype=float).reshape(-1, 1)
82+
83+
z = np.column_stack([yf, yp, xp])
84+
eps = knn.kth_neighbor_distance(z, k=k)
85+
86+
sp_yp = yp
87+
sp_fp = np.column_stack([yf, yp])
88+
sp_px = np.column_stack([yp, xp])
89+
90+
n_yp = knn.count_within_radius(sp_yp, eps)
91+
n_fp = knn.count_within_radius(sp_fp, eps)
92+
n_px = knn.count_within_radius(sp_px, eps)
93+
94+
terms = digamma(n_yp + 1) - digamma(n_fp + 1) - digamma(n_px + 1)
95+
te_nats = digamma(k) + np.mean(terms)
96+
return float(te_nats / _LN2)
97+
98+
99+
def ksg_mutual_information(a, b, k=4):
100+
"""KSG (Kraskov algorithm 1) mutual information between a and b, in NATS.
101+
102+
a, b are (n, da) and (n, db). Returns I(a; b) in nats so it can be checked
103+
against the analytic -0.5 ln(1 - r^2) for correlated Gaussians.
104+
"""
105+
a = np.asarray(a, dtype=float)
106+
b = np.asarray(b, dtype=float)
107+
n = len(a)
108+
joint = np.column_stack([a, b])
109+
eps = knn.kth_neighbor_distance(joint, k=k)
110+
n_a = knn.count_within_radius(a, eps)
111+
n_b = knn.count_within_radius(b, eps)
112+
mi = digamma(k) + digamma(n) - np.mean(digamma(n_a + 1) + digamma(n_b + 1))
113+
return float(mi)

entroscope/transfer.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""Transfer entropy — directional information flow X->Y (Schreiber 2000).
2+
3+
The library's first bivariate measure: two series in, one directional scalar out.
4+
Two estimators: ``method="ksg"`` (Kraskov k-NN, no binning, default) and
5+
``method="binned"`` (histogram). Math lives in ``_transfer_estimators``.
6+
"""
7+
8+
import warnings
9+
10+
import matplotlib.pyplot as plt
11+
import numpy as np
12+
import pandas as pd
13+
14+
from . import _core
15+
from . import _transfer_estimators as est
16+
17+
_EMBED_DIM = 3 # history-length-1 joint space (y_future, y_past, x_past)
18+
19+
20+
def _coerce_pair(x, y):
21+
xa, _ = _core.as_array(x)
22+
ya, yindex = _core.as_array(y)
23+
if len(xa) != len(ya):
24+
raise ValueError(f"x and y must be equal length ({len(xa)} != {len(ya)})")
25+
return xa, ya, yindex
26+
27+
28+
def _guard(n_samples, k, method):
29+
if n_samples < 10 * _EMBED_DIM:
30+
warnings.warn(
31+
f"sample size ({n_samples}) is small for embedding dimension "
32+
f"{_EMBED_DIM}; transfer-entropy estimate may be unreliable",
33+
UserWarning,
34+
stacklevel=3,
35+
)
36+
37+
38+
def _estimate(xa, ya, k, lag, method, bins):
39+
yf, yp, xp = est.embed(xa, ya, lag=lag)
40+
_guard(len(yf), k, method)
41+
if method == "ksg":
42+
return est.te_ksg(yf, yp, xp, k=k)
43+
if method == "binned":
44+
return est.te_binned(yf, yp, xp, bins=bins)
45+
raise ValueError(f"unknown method {method!r}; use 'ksg' or 'binned'")
46+
47+
48+
def compute(x, y, *, k=4, lag=1, method="ksg", bins=6):
49+
"""Transfer entropy TE(X->Y) as a float (bits)."""
50+
xa, ya, _ = _coerce_pair(x, y)
51+
return _estimate(xa, ya, k, lag, method, bins)
52+
53+
54+
def rolling(x, y, window=120, *, k=4, lag=1, method="ksg", bins=6):
55+
"""Rolling TE(X->Y) over sliding windows. Series-in -> Series-out.
56+
57+
Note: with method="ksg" this runs a k-NN search per window, so it is
58+
noticeably slower than the single-series rolling measures on long inputs.
59+
"""
60+
xa, ya, yindex = _coerce_pair(x, y)
61+
n = len(xa)
62+
if window <= _EMBED_DIM + lag:
63+
raise ValueError(f"window ({window}) must exceed embedding+lag ({_EMBED_DIM + lag})")
64+
if window > n:
65+
raise ValueError(f"window ({window}) larger than series length ({n})")
66+
out = np.full(n, np.nan)
67+
for end in range(window, n + 1):
68+
wx = xa[end - window : end]
69+
wy = ya[end - window : end]
70+
out[end - 1] = _estimate(wx, wy, k, lag, method, bins)
71+
return _core.wrap(out, yindex)
72+
73+
74+
def delta(x, y, window=120, *, k=4, lag=1, method="ksg", bins=6):
75+
"""First difference of the rolling transfer entropy."""
76+
roll = rolling(x, y, window, k=k, lag=lag, method=method, bins=bins)
77+
if isinstance(roll, pd.Series):
78+
return roll.diff()
79+
out = np.full_like(roll, np.nan)
80+
out[1:] = np.diff(roll)
81+
return out
82+
83+
84+
def plot(x, y, window=120, *, k=4, lag=1, method="ksg", bins=6, title=None):
85+
"""Figure of rolling TE(X->Y). Never calls plt.show()."""
86+
roll = rolling(x, y, window, k=k, lag=lag, method=method, bins=bins)
87+
fig, ax = plt.subplots(figsize=(10, 4))
88+
if isinstance(roll, pd.Series):
89+
ax.plot(roll.index, roll.to_numpy())
90+
else:
91+
ax.plot(range(len(roll)), roll)
92+
ax.set_title(title or f"Rolling transfer entropy (window={window})")
93+
ax.set_xlabel("position")
94+
ax.set_ylabel("transfer entropy (bits)")
95+
fig.tight_layout()
96+
return fig

entroscope/utils/knn.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""k-nearest-neighbor helpers for KSG-style estimators (Chebyshev / max norm)."""
2+
3+
import numpy as np
4+
from scipy.spatial import cKDTree
5+
6+
7+
def kth_neighbor_distance(points, k):
8+
"""Chebyshev distance to the k-th nearest neighbor of each point.
9+
10+
`points` is (n, d). Returns length-n array. Self (distance 0) is excluded by
11+
querying k+1 neighbors and dropping the first.
12+
"""
13+
points = np.asarray(points, dtype=float)
14+
tree = cKDTree(points)
15+
# query returns the point itself first (distance 0); take the (k+1)-th column.
16+
dists, _ = tree.query(points, k=k + 1, p=np.inf)
17+
return np.asarray(dists)[:, k]
18+
19+
20+
def count_within_radius(points, radii):
21+
"""For each point, count OTHER points with Chebyshev distance strictly < radius.
22+
23+
`points` is (n, d); `radii` is length n. Excludes the point itself.
24+
"""
25+
points = np.asarray(points, dtype=float)
26+
radii = np.asarray(radii, dtype=float)
27+
tree = cKDTree(points)
28+
counts = np.empty(len(points), dtype=int)
29+
for i, (p, r) in enumerate(zip(points, radii)):
30+
# ball_point with p=inf, count neighbors strictly inside r, minus self.
31+
idx = tree.query_ball_point(p, r=r, p=np.inf)
32+
# exclude self and any point exactly at radius r (strict inequality).
33+
c = 0
34+
for j in idx:
35+
if j == i:
36+
continue
37+
if np.max(np.abs(points[j] - p)) < r:
38+
c += 1
39+
counts[i] = c
40+
return counts

examples/_synthetic.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,51 @@ def qc_sensor(seed=13):
131131
out_of_control = 50 + np.linspace(0, 3, 400) + 1.5 * rng.randn(400)
132132
values = np.concatenate([in_control, out_of_control])
133133
return pd.Series(values, name="dimension_mm")
134+
135+
136+
def correlated_assets(seed=14):
137+
"""Two asset return streams whose correlation regime breaks down halfway.
138+
139+
First half: both are driven by a shared market factor, so their rolling
140+
correlation sits in a stable, high band. Second half: the shared driver
141+
fades and a sign-flipping component takes over, so the correlation wanders
142+
all over the place. The *correlation series itself* goes from steady to
143+
erratic — that is what entropy of the rolling correlation is meant to catch.
144+
145+
Returns a 2-column DataFrame of daily returns (``a``, ``b``).
146+
"""
147+
rng = _rng(seed)
148+
n = 400
149+
150+
# Stable regime: strong shared factor + small idiosyncratic noise.
151+
factor = rng.randn(n)
152+
a_stable = factor + 0.4 * rng.randn(n)
153+
b_stable = factor + 0.4 * rng.randn(n)
154+
155+
# Breakdown regime: shared factor weakens and a slow sign-flipping driver
156+
# drags the correlation up and down, so it never settles.
157+
factor2 = rng.randn(n)
158+
flip = np.sin(np.linspace(0, 6 * np.pi, n)) # swings the coupling sign
159+
a_unstable = factor2 + 0.8 * rng.randn(n)
160+
b_unstable = flip * factor2 + 0.8 * rng.randn(n)
161+
162+
a = np.concatenate([a_stable, a_unstable])
163+
b = np.concatenate([b_stable, b_unstable])
164+
idx = pd.date_range("2025-01-01", periods=len(a), freq="B")
165+
return pd.DataFrame({"a": a, "b": b}, index=idx)
166+
167+
168+
def lead_lag_assets(seed=15):
169+
"""Two assets where A leads B by one step, plus reverse-direction noise.
170+
171+
A drives B (B_t depends on A_{t-1}), so transfer entropy should be clearly
172+
larger in the A->B direction than B->A. Returns a 2-column DataFrame (a, b).
173+
"""
174+
rng = _rng(seed)
175+
n = 600
176+
a = rng.randn(n)
177+
b = np.empty(n)
178+
b[0] = rng.randn()
179+
b[1:] = a[:-1] + 0.3 * rng.randn(n - 1)
180+
idx = pd.date_range("2025-01-01", periods=n, freq="B")
181+
return pd.DataFrame({"a": a, "b": b}, index=idx)

0 commit comments

Comments
 (0)