Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ jobs:
strategy:
matrix:
python-version: ["3.12"]
os: [ubuntu-latest]
name: Generate documentation
runs-on: ubuntu-latest
environment:
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ jobs:
strategy:
matrix:
python-version: ["3.12"]
os: [ubuntu-latest]
name: Run Pylint code analyzer
needs: run-black
runs-on: ubuntu-latest
Expand Down
22 changes: 11 additions & 11 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ on:
pull_request:
branches:
- '**'
schedule: # Every Monday at 04:00 UTC
- cron: '0 4 * * 1'

jobs:
caching:
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
os: [ubuntu-latest]
os: [ubuntu-latest, windows-latest, macos-latest]
fail-fast: false

name: Cache for ${{ matrix.python-version }} on ${{ matrix.os }}
Expand All @@ -45,7 +47,7 @@ jobs:
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
os: [ubuntu-latest]
os: [ubuntu-latest, windows-latest, macos-latest]
submodule:
- { name: "All Tests", pytest_args: "tests/" }
fail-fast: false
Expand Down Expand Up @@ -81,25 +83,21 @@ jobs:
- name: "Run tests ${{ matrix.submodule.name }} with coverage"
if: matrix.python-version == '3.12' && matrix.os == 'ubuntu-latest'
run: |
pytest ${{ matrix.submodule.pytest_args }} --cov=delaynet
pytest --cov=delaynet ${{ matrix.submodule.pytest_args }}
env:
COVERAGE_FILE: ".coverage.${{ matrix.submodule.name }}"

- name: Store coverage file
if: matrix.python-version == '3.12' && matrix.os == 'ubuntu-latest'
uses: actions/upload-artifact@v4
with:
name: coverage
name: coverage-${{ matrix.submodule.name }}
path: ".coverage.${{ matrix.submodule.name }}"

coverage:
name: Merge Coverage
needs: run-tests
strategy:
matrix:
python-version: ["3.12"]
os: [ubuntu-latest]
runs-on: ${{ matrix.os }}
runs-on: ubuntu-latest
defaults:
run:
shell: bash -el {0}
Expand All @@ -112,13 +110,15 @@ jobs:
uses: actions/download-artifact@v4
id: download
with:
name: 'coverage'
path: coverage
pattern: 'coverage-*'
merge-multiple: true

- name: Install coverage
run: pip install coverage

- name: Merge coverage files
run: coverage combine
run: coverage combine coverage/

- name: Upload coverage report to Codecov
uses: codecov/codecov-action@v4
Expand Down
11 changes: 8 additions & 3 deletions delaynet/connectivities/granger.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ def gt_single_lag(ts1, ts2, lag_step: int = 5):
return np.squeeze(ftres.pvalue)[()]


@connectivity
def gt_multi_lag(ts1, ts2, max_lag_steps: int = 5):
"""Granger Causality (GC) connectivity metric with variable time lag.

Expand All @@ -84,9 +85,10 @@ def gt_multi_lag(ts1, ts2, max_lag_steps: int = 5):
gt_single_lag(ts1, ts2, lag_step) for lag_step in range(1, max_lag_steps + 1)
]
idx_min = min(range(len(all_p_values)), key=all_p_values.__getitem__)
return np.min(all_p_values), idx_min
return all_p_values[idx_min], idx_min


@connectivity
def gt_multi_lag_statsmodels(ts1, ts2, max_lag_steps: int = 5):
"""Granger Causality (GC) connectivity metric with variable time lag.

Expand All @@ -108,9 +110,10 @@ def gt_multi_lag_statsmodels(ts1, ts2, max_lag_steps: int = 5):
for lag_step in range(1, max_lag_steps + 1)
]
idx_min = min(range(len(all_p_values)), key=all_p_values.__getitem__)
return np.min(all_p_values), idx_min
return all_p_values[idx_min], idx_min


@connectivity
def gt_bi_multi_lag(ts1, ts2, max_lag_steps: int = 5):
"""Bidirectional Granger Causality (GC) connectivity metric with variable time lag.

Expand Down Expand Up @@ -156,4 +159,6 @@ def gt_bi_multi_lag(ts1, ts2, max_lag_steps: int = 5):

all_p_values[lag_step - 1] = (ft_xy - ft_yx) - (f_xy - f_yx)

return np.max(all_p_values), all_p_values
# Determine the maximal difference between the two directions, i.e. a->b and b->a
idx_max = max(range(len(all_p_values)), key=all_p_values.__getitem__)
return all_p_values[idx_max][0], idx_max
6 changes: 5 additions & 1 deletion delaynet/connectivities/mutual_information.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
from ..decorators import connectivity


@connectivity(mcb_kwargs={"n_bins": 3, "alphabet": "ordinal", "strategy": "quantile"})
@connectivity(
check_symbolic=True,
entropy_like=True,
mcb_kwargs={"n_bins": 3, "alphabet": "ordinal", "strategy": "quantile"},
)
def mutual_information(
ts1, ts2, base1: int = 3, base2: int = 3, max_lag_steps: int = 5
):
Expand Down
2 changes: 1 addition & 1 deletion delaynet/connectivities/ordinal_synchronization.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def ordinal_synchronization(ts1, ts2, d: int = 3, tau: int = 1, max_lag_steps: i
for k in range(max_lag_steps + 1)
]
idx_max = np.argmax(np.abs(os))
return 1.0 / np.max(np.abs(os)), idx_max
return 1.0 / np.abs(os[idx_max]), idx_max


# pylint: disable=too-many-locals
Expand Down
6 changes: 5 additions & 1 deletion delaynet/connectivities/transfer_entropy.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
from ..decorators import connectivity


@connectivity(mcb_kwargs={"n_bins": 2, "alphabet": "ordinal", "strategy": "quantile"})
@connectivity(
check_symbolic=True,
entropy_like=True,
mcb_kwargs={"n_bins": 2, "alphabet": "ordinal", "strategy": "quantile"},
)
def transfer_entropy(ts1, ts2):
r"""
Transfer Entropy (TE) connectivity metric.
Expand Down
63 changes: 61 additions & 2 deletions delaynet/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,17 @@

from .utils.bind_args import bind_args
from .utils.multi_coeff_binning import MultipleCoefficientBinning

from .utils.symbolic import check_symbolic_pairwise, to_symbolic

Connectivity = Callable[[ndarray, ndarray, ...], float | tuple[float, int]]
Norm = Callable[[ndarray, ...], ndarray]


def connectivity(
*args,
entropy_like: bool = False,
check_symbolic: bool | int | None = False,
default_to_symbolic: bool | int | None = False,
mcb_kwargs: dict | None = None,
):
"""Decorator for the connectivity functions.
Expand All @@ -32,6 +35,16 @@ def connectivity(

Shape of the input time series must be equal.

:param entropy_like: If ``True``, mark the connectivity as entropy-like.
:type entropy_like: bool
:param check_symbolic: If ``True``, check if the connectivity values are symbolic.
A specific number of unique symbols can be set (``None`` for
no limit).
Necessary for entropy-based connectivities.
:type check_symbolic: bool | int
:param default_to_symbolic: If ``True``, arrays were checked and not symbolic,
they are converted to symbolic. Otherwise, an error is
raised.
:param mcb_kwargs: Keyword arguments for the :py:class:`MultipleCoefficientBinning`
transformer. If ``None``, no binning is applied.
:type mcb_kwargs: dict | None
Expand All @@ -51,7 +64,11 @@ def connectivity_outer(connectivity_func: Connectivity) -> Connectivity:

@wraps(connectivity_func)
def wrapper(
ts1: ndarray, ts2: ndarray, *args, **kwargs
ts1: ndarray,
ts2: ndarray,
*args,
symbolic_bins: bool | int | None = False,
**kwargs,
) -> float | tuple[float, int]:
"""Wrapper for the connectivity functions.

Expand All @@ -67,6 +84,12 @@ def wrapper(
:type ts2: ndarray
:param args: The args to pass to the connectivity function.
:type args: list
:param symbolic_bins: ``True``, ``Ǹone`` or an integer, the arrays ar
converted to symbolic. If ``True`` or ``None``, no
limit is set.
If an integer, the arrays are digitized into
``max_symbols`` bins on [0, max_symbols-1].
:type symbolic_bins: bool | int | None
:param kwargs: The kwargs to pass to the connectivity function.
:type kwargs: dict
:return: Connectivity value and lag (if applicable).
Expand All @@ -90,6 +113,39 @@ def wrapper(
f"but have shapes {ts1.shape} and {ts2.shape}."
)

# Convert to symbolic if necessary
conversion_condition = (
check_symbolic
and (ts1.dtype.kind in "f" or ts2.dtype.kind in "f")
and (default_to_symbolic or default_to_symbolic is None)
)
if conversion_condition or (symbolic_bins or symbolic_bins is None):
ts1 = to_symbolic(
ts1,
max_symbols=(
symbolic_bins
if (symbolic_bins or symbolic_bins is None)
else default_to_symbolic
),
)
ts2 = to_symbolic(
ts2,
max_symbols=(
symbolic_bins
if (symbolic_bins or symbolic_bins is None)
else default_to_symbolic
),
)

# Check if the time series are symbolic
if check_symbolic or check_symbolic is None:
check_symbolic_pairwise(
ts1,
ts2,
max_symbols=None if check_symbolic is True else check_symbolic,
# if check_symbolic is True, no limit is set
)

# Multiple Coefficient Binning (MCB)
if mcb_kwargs is not None:
ts1 = bin_timeseries(ts1, mcb_kwargs)
Expand Down Expand Up @@ -143,6 +199,9 @@ def bin_timeseries(ts: ndarray, binning_kwargs: dict) -> ndarray:
ts = transformer.transform(ts.reshape(-1, 1))[:, 0]
return ts

# Mark connectivity as entropy-likeness
wrapper.is_entropy_like = entropy_like

return wrapper

# Usage without parentheses
Expand Down
82 changes: 82 additions & 0 deletions delaynet/utils/symbolic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Function to check if numpy array(s) are symbolic.
Used for entropy-based connectivity metrics.
"""

from numpy import unique, concatenate, ndarray

from ..utils.logging import logging


def check_symbolic_pairwise(
array1: ndarray, array2: ndarray, max_symbols: int | None = None
) -> None:
"""
Check if two numpy arrays are symbolic.

Accepted are integer arrays, signed or unsigned, but not float arrays.

Together, both arrays should not have more than ``max_symbols`` unique symbols.

:param array1: The first numpy array to check.
:type array1: ndarray
:param array2: The second numpy array to check.
:type array2: ndarray
:param max_symbols: The maximum number of unique symbols allowed.
:type max_symbols: int
:raises ValueError: If the arrays are not symbolic.
:raises ValueError: If ``max_symbols`` is <= 0.
:raises ValueError: If the arrays have more than ``max_symbols`` unique symbols.

"""
if array1.dtype.kind == "f" or array2.dtype.kind == "f":
logging.error(
"Set `symbolic_bins` in connectivity() to explicitly convert to symbolic."
)
raise ValueError("Input arrays cannot be of float type.")
if array1.dtype.kind in "iu" and array2.dtype.kind in "iu":
if max_symbols is None:
return # If no limit is set, return
if max_symbols <= 0:
raise ValueError("max_symbols must be greater than 0.")
unique_symbols = unique(concatenate((unique(array1), unique(array2))))
if len(unique_symbols) > max_symbols:
raise ValueError(
f"Input arrays have more than {max_symbols} unique symbols."
)
else:
raise ValueError("Input arrays must be of integer type.")


def to_symbolic(array: ndarray[float], max_symbols: int | None = None) -> ndarray[int]:
"""
Convert a numpy array to symbolic. (float to int)

Converts float arrays to integer arrays.
Either rounding to the next integer, or when ``max_symbols`` is set,
digitizing the array into ``max_symbols`` bins on [0, max_symbols-1].

:param array: The numpy array to convert.
:type array: ndarray
:param max_symbols: The maximum number of unique symbols allowed.
Default is None, which means no limit.
:type max_symbols: int | None
:return: The symbolic numpy array.
:rtype: ndarray
:raises ValueError: If ``max_symbols`` is <= 0.
:raises ValueError: If the array is not of float type.
"""
if max_symbols is not None and max_symbols <= 0:
raise ValueError("max_symbols must be greater than 0.")
if array.dtype.kind == "f":
if max_symbols is None:
logging.warning("Converting to symbolic, only rounding to integer.")
return array.round().astype(int)
logging.warning(f"Converting to symbolic with max_symbols={max_symbols}.")
return (
( # Stretch the array linearly to [0, max_symbols-1]
(array - array.min()) / (array.max() - array.min()) * (max_symbols - 1)
)
.round()
.astype(int)
)
raise ValueError("Input array must be of float type to convert.")
9 changes: 9 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@
Changelog
*********

Version 0.2.1 (2024-0X-XX)
**************************

* `PR 22 <https://github.com/cbueth/delaynet/pull/22>`_: Enhancements and Fixes for Entropy-based Connectivities

- **Added**: Conversion `to_symbolic` for connectivities, function attribute `entropy_like`, check for symbolic time series connectivities, and connectivity decorator tests.
- **Changed**: CI now includes windows and mac test runners, scheduled tests, coverage combine, and updated artifact action.
- **Fixed**: Issue with `gt_bi_multi_lag` returning non-optimal idx.

Version 0.2.0 (2024-03-15)
**************************

Expand Down
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ dependencies = [
"statsmodels",
"scikit-learn",
"numba",
"mkl",
]

[project.urls]
Expand Down Expand Up @@ -63,7 +62,7 @@ version = { attr = "delaynet._version.__version__" }

# --------------------------------------------------------------------------------------
# Linting
# --------------------------------------------------------------------------------------b
# --------------------------------------------------------------------------------------
[tool.pylint.main]
# Number of processes to use to do the linting.
jobs = 5
Expand Down
1 change: 0 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ scipy
statsmodels
scikit-learn
numba
mkl
# Code Quality
blackd
isort
Expand Down
Loading