|
| 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 |
0 commit comments