# This file is part of pyunicorn.
# Copyright (C) 2008--2024 Jonathan F. Donges and pyunicorn authors
# URL: <https://www.pik-potsdam.de/members/donges/software-2/software>
# License: BSD (3-clause)
#
# Please acknowledge and cite the use of this software and its authors
# when results are used in publications or published elsewhere.
#
# You can use the following reference:
# J.F. Donges, J. Heitzig, B. Beronov, M. Wiedermann, J. Runge, Q.-Y. Feng,
# L. Tupikina, V. Stolbova, R.V. Donner, N. Marwan, H.A. Dijkstra,
# and J. Kurths, "Unified functional network and nonlinear time series analysis
# for complex systems science: The pyunicorn package"
"""
Provides class for event series analysis, namely event synchronization (ES) and
event coincidence analysis (ECA). In addition, a method for the generation of
binary event series from continuous time series data is included.
When instantiating a class, data must either be passed as an event
matrix (for details see below) or as a continuous time series. Using the class,
an ES or ECA matrix can be calculated to generate a climate network using the
EventSeriesClimateNetwork class. Both ES and ECA may be called without
instantiating an object of the class.
Significance levels are provided using analytic calculations using Poisson
point processes as a null model (for ECA only) or a Monte Carlo approach.
Modifications vs. original pyunicorn EventSeries
-------------------------------------------------
Modification 1 (constructor):
Removed the axis-swap heuristic that transposed data when the number of
variables exceeded the number of timesteps. The heuristic assumed that
datasets with more variables than timesteps were transposed by mistake,
which is not always true.
Modification 2 (make_event_matrix):
Replaced np.quantile with np.nanquantile so that time series containing
NaN values are handled gracefully instead of raising an error.
Modification 3 (matrix loops):
Added tqdm progress bars to _ndim_event_synchronization and
_ndim_event_coincidence_analysis for visual feedback during long runs.
Modification 4 (getters):
Added get_T(), get_N(), get_timestamps(), get_taumax(), get_lag() accessor
methods to expose key instance attributes without name-mangling gymnastics.
Optimization 1 — Sparse binary-search ES with lag support (finite taumax):
For finite taumax, precomputes sorted inner-event arrays once per node and
uses np.searchsorted to find only the event pairs within the lag-shifted
taumax window. Complexity O(N²·L·taumax/T) vs O(N²·L²).
Results are exact (atol < 1e-12 vs pairwise reference).
Optimization 2 — Blocked dense ES (infinite taumax, lag=0):
Falls back to a 4-D blocked-dense tensor kernel that vectorises the
double-count correction loop — the main bottleneck in the original code.
Block size is tunable; joblib parallelization is supported.
Optimization 3 — Vectorized ECA via cumsum + BLAS (integer timestamps):
For uniform integer timestamps and integer-valued lag, builds lag=0 smeared
indicator matrices with cumsum rolling windows, shifts by lag steps, then
uses a single BLAS matrix multiply. Complexity O(T·N + N²).
Precision is identical to pairwise within float32 rounding (atol < 1e-6).
Optimization 4 — Optional joblib parallelization:
All kernel dispatchers accept an n_jobs keyword argument. If joblib is
installed, computation is distributed across n_jobs worker processes.
Falls back to a single-process loop when joblib is absent.
Contributors
------------
Guruprem Bishnoi — Modifications 1–4 and Optimizations 1–4 (2026)
"""
from typing import Tuple
from collections.abc import Hashable
import warnings
import numpy as np
from scipy import stats
from tqdm import tqdm
# Optimization 4: optional joblib for parallelization
try:
from joblib import Parallel, delayed
_JOBLIB_AVAILABLE = True
except ImportError:
_JOBLIB_AVAILABLE = False
from ..core.cache import Cached
# ===========================================================================
# Module-level helpers required by the fast-path ES kernels
# (kept at module level so that joblib can pickle them for multi-processing)
# ===========================================================================
# Optimization 1 / 2 helper -------------------------------------------------
[docs]
def _pad_block(node_data, indices):
"""
Pad inner-event arrays for a block of nodes into dense 2-D arrays.
Used by the blocked-dense ES kernel (Optimization 2).
"""
max_len = max((node_data[i]['inner_count'] for i in indices), default=0)
nA = len(indices)
times = np.zeros((nA, max_len), dtype=np.float64)
tau2 = np.zeros((nA, max_len), dtype=np.float64)
mask = np.zeros((nA, max_len), dtype=bool)
counts = np.zeros(nA, dtype=np.int32)
for row, idx in enumerate(indices):
info = node_data[idx]
length = info['inner_count']
counts[row] = length
if length:
times[row, :length] = info['inner_times']
tau2[row, :length] = info['tau2_inner']
mask[row, :length] = True
return times, tau2, mask, counts
[docs]
def _compute_es_block(node_data, block_a, block_b):
"""
Exact ES for all node pairs in two blocks via 4-D dense boolean tensors.
Vectorises the double-count correction loop that is the bottleneck in
pyunicorn's pairwise code. Used by the blocked-dense kernel (Opt 2).
"""
A_times, A_tau2, A_mask, A_counts = _pad_block(node_data, block_a)
B_times, B_tau2, B_mask, B_counts = _pad_block(node_data, block_b)
block_xy = np.zeros((len(block_a), len(block_b)), dtype=np.float64)
block_yx = np.zeros((len(block_a), len(block_b)), dtype=np.float64)
if A_times.shape[1] == 0 or B_times.shape[1] == 0:
return block_xy, block_yx
valid = A_mask[:, None, :, None] & B_mask[None, :, None, :]
D2 = 2.0 * (A_times[:, None, :, None] - B_times[None, :, None, :])
tau2 = np.minimum(A_tau2[:, None, :, None], B_tau2[None, :, None, :])
Axy = valid & (D2 > 0.0) & (D2 <= tau2)
Ayx = valid & (D2 < 0.0) & (D2 >= -tau2)
eqtime = valid & (D2 == 0.0)
row_any_Ayx = Ayx.any(axis=3, keepdims=True)
col_any_Ayx = Ayx.any(axis=2, keepdims=True)
row_any_Axy = Axy.any(axis=3, keepdims=True)
col_any_Axy = Axy.any(axis=2, keepdims=True)
countxy = Axy.sum(axis=(2, 3)).astype(np.float64)
countyx = Ayx.sum(axis=(2, 3)).astype(np.float64)
eqcount = eqtime.sum(axis=(2, 3)).astype(np.float64)
countxydouble = (Axy & (row_any_Ayx | col_any_Ayx)).sum(
axis=(2, 3)).astype(np.float64)
countyxdouble = (Ayx & (row_any_Axy | col_any_Axy)).sum(
axis=(2, 3)).astype(np.float64)
numer_xy = countxy + 0.5 * eqcount - 0.5 * countxydouble
numer_yx = countyx + 0.5 * eqcount - 0.5 * countyxdouble
A_cf = A_counts[:, None].astype(np.float64)
B_cf = B_counts[None, :].astype(np.float64)
denom = np.sqrt(A_cf * B_cf)
good = denom > 0.0
block_xy[good] = numer_xy[good] / denom[good]
block_yx[good] = numer_yx[good] / denom[good]
return block_xy, block_yx
[docs]
def _compute_es_pair_sparse_lag(tx, tau2x, ty, tau2y, *, taumax, lag):
"""
Exact ES for one pair using sorted inner-event times + binary search,
extended to support any scalar lag (Optimization 1).
Parameters
----------
tx, tau2x : 1-D float64 — inner event times and per-event tau2 for X
ty, tau2y : 1-D float64 — inner event times and per-event tau2 for Y
taumax : float — coincidence window cap (must be finite)
lag : float — time lag (Y is conceptually shifted by +lag)
Returns
-------
(countxy / norm, countyx / norm) : float64 pair
"""
lx, ly = len(tx), len(ty)
if lx == 0 or ly == 0:
return 0.0, 0.0
norm = np.sqrt(float(lx * ly))
if norm == 0.0:
return 0.0, 0.0
# Binary search with lag-shifted bounds
lo = np.searchsorted(ty, tx - lag - taumax, side='left') # (lx,)
hi = np.searchsorted(ty, tx - lag + taumax, side='right') # (lx,)
counts = hi - lo # (lx,) >= 0
total = int(counts.sum())
if total == 0:
return 0.0, 0.0
cumcounts_lo = np.concatenate([[0], np.cumsum(counts[:-1])])
offsets = np.arange(total, dtype=np.intp) - np.repeat(cumcounts_lo, counts)
k_inds = np.repeat(np.arange(lx, dtype=np.intp), counts)
l_inds = np.repeat(lo, counts).astype(np.intp) + offsets
D2_sp = 2.0 * (tx[k_inds] - ty[l_inds] - lag)
tau2_sp = np.minimum(tau2x[k_inds], tau2y[l_inds])
Axy_sp = (D2_sp > 0.0) & (D2_sp <= tau2_sp)
Ayx_sp = (D2_sp < 0.0) & (D2_sp >= -tau2_sp)
eq_sp = D2_sp == 0.0
eqtime = float(eq_sp.sum())
row_any_Ayx = np.zeros(lx, dtype=bool)
col_any_Ayx = np.zeros(ly, dtype=bool)
row_any_Axy = np.zeros(lx, dtype=bool)
col_any_Axy = np.zeros(ly, dtype=bool)
k_ayx = k_inds[Ayx_sp]
l_ayx = l_inds[Ayx_sp]
k_axy = k_inds[Axy_sp]
l_axy = l_inds[Axy_sp]
if len(k_ayx):
np.logical_or.at(row_any_Ayx, k_ayx, True)
np.logical_or.at(col_any_Ayx, l_ayx, True)
if len(k_axy):
np.logical_or.at(row_any_Axy, k_axy, True)
np.logical_or.at(col_any_Axy, l_axy, True)
countxydouble = float(np.sum(
Axy_sp & (row_any_Ayx[k_inds] | col_any_Ayx[l_inds])
))
countyxdouble = float(np.sum(
Ayx_sp & (row_any_Axy[k_inds] | col_any_Axy[l_inds])
))
countxy = float(Axy_sp.sum()) + 0.5 * eqtime - 0.5 * countxydouble
countyx = float(Ayx_sp.sum()) + 0.5 * eqtime - 0.5 * countyxdouble
return countxy / norm, countyx / norm
[docs]
def _process_es_rows_sparse_lag(r_start, r_end, N, node_data, *, taumax, lag):
"""
Process all pairs (i, j > i) where i in [r_start, r_end) — row-based
chunk for joblib (Optimization 1 / 4).
"""
results = []
for i in range(r_start, r_end):
ndi = node_data[i]
for j in range(i + 1, N):
ndj = node_data[j]
if ndi['inner_count'] == 0 or ndj['inner_count'] == 0:
results.append((i, j, 0.0, 0.0))
else:
xy, yx = _compute_es_pair_sparse_lag(
ndi['inner_times'], ndi['tau2_inner'],
ndj['inner_times'], ndj['tau2_inner'],
taumax=taumax, lag=lag,
)
results.append((i, j, xy, yx))
return results
# ===========================================================================
# Main class
# ===========================================================================
[docs]
class EventSeries(Cached):
[docs]
def __init__(self, data, *, timestamps=None, taumax=np.inf, lag=0.0,
threshold_method=None, threshold_values=None,
threshold_types=None):
"""
Initialize an instance of EventSeries. Input data must be a 2D numpy
array with time as the first axis and variables as the second axis.
Event data is stored as an eventmatrix.
Format of eventmatrix:
An eventmatrix is a 2D numpy array with the first dimension covering
the timesteps and the second dimensions covering the variables. Each
variable at a specific timestep is either '1' if an event occured or
'0' if it did not, e.g. for 3 variables with 10 timesteps the
eventmatrix could look like
array([[0, 1, 0],
[1, 0, 1],
[0, 0, 0],
[1, 0, 1],
[0, 1, 0],
[0, 0, 0],
[1, 0, 0],
[0, 0, 1],
[0, 1, 0],
[0, 0, 0]])
If input data is not provided as an eventmatrix, the constructor tries
to generate one using the make_event_matrix method. Default keyword
arguments are used in this case.
:type data: 2D Numpy array [time, variables]
:arg data: Event series array or array of non-binary variable values
:type timestamps: 1D Numpy array
:arg timestamps: Time points of events of data. If not provided,
integer values are used
:type taumax: float
:arg taumax: maximum time difference between two events to be
considered synchronous. Caution: For ES, the default is
np.inf because of the intrinsic dynamic coincidence
interval in ES. For ECA, taumax is a parameter of the
method that needs to be defined!
:type lag: float
:arg lag: extra time lag between the event series
:type threshold_method: str 'quantile' or 'value' or 1D numpy array or
str 'quantile' or 'value'
:arg threshold_method: specifies the method for generating a binary
event matrix from an array of continuous time
series. Default: None
:type threshold_values: 1D Numpy array or float
:arg threshold_values: quantile or real number determining threshold
for each variable. Default: None.
:type threshold_types: str 'above' or 'below' or 1D list of strings
'above' or 'below'
:arg threshold_types: Determines for each variable if event is below
or above threshold
"""
if threshold_method is None:
# Check if data contains only binary values
if len(np.unique(data)) != 2 or not (
np.unique(data) == np.array([0, 1])).all():
raise IOError("Event matrix not in correct format")
# Save event matrix
self.__T = data.shape[0]
self.__N = data.shape[1]
self.__eventmatrix = data
else:
# If data is not in eventmatrix format, use method
# make_event_matrix to transform continuous time series to a binary
# time series
# Modification 1: Removed the axis-swap heuristic that previously
# transposed data when data.shape[1] > data.shape[0]. That
# heuristic assumed datasets with more variables than timesteps
# were accidentally transposed, which is not always true.
if isinstance(data, np.ndarray):
self.__eventmatrix = \
self.make_event_matrix(data,
threshold_method=threshold_method,
threshold_values=threshold_values,
threshold_types=threshold_types)
self.__T = self.__eventmatrix.shape[0]
self.__N = self.__eventmatrix.shape[1]
else:
raise IOError('Input data is not in event matrix format!')
# If no timestamps are given, use integer array indices as timestamps
if timestamps is not None:
if timestamps.shape[0] != self.__T:
raise IOError("Timestamps array has not the same length as"
" event matrix!")
self.__timestamps = timestamps
else:
self.__timestamps = np.linspace(0.0, self.__T - 1, self.__T)
self.__taumax = float(taumax)
self.__lag = float(lag)
# save number of events
NrOfEvs = np.array(np.sum(self.__eventmatrix, axis=0), dtype=int)
self.__nrofevents = NrOfEvs
# Dictionary of symmetrization functions for later use
self.symmetrization_options = {
'directed': EventSeries._symmetrization_directed,
'symmetric': EventSeries._symmetrization_symmetric,
'antisym': EventSeries._symmetrization_antisym,
'mean': EventSeries._symmetrization_mean,
'max': EventSeries._symmetrization_max,
'min': EventSeries._symmetrization_min
}
[docs]
def __cache_state__(self) -> Tuple[Hashable, ...]:
# The following attributes are assumed immutable:
# (__eventmatrix, __timestamps, __taumax, __lag)
return ()
[docs]
def __str__(self):
"""
Return a string representation of the EventSeries object.
"""
return (f"EventSeries: {self.__N} variables, "
f"{self.__T} timesteps, taumax: {self.__taumax:.1f}, "
f"lag: {self.__lag:.1f}")
def get_event_matrix(self):
return self.__eventmatrix
# Modification 4: Added getter methods so callers can access key instance
# attributes without relying on name-mangling.
def get_T(self):
return self.__T
def get_N(self):
return self.__N
def get_timestamps(self):
return self.__timestamps
def get_taumax(self):
return self.__taumax
def get_lag(self):
return self.__lag
[docs]
@staticmethod
def _symmetrization_directed(matrix):
"""
Helper function for symmetrization options
:type matrix: 2D numpy array
:arg matrix: pairwise ECA/ES scores of data
:rtype: 2D numpy array
:return: original matrix
"""
return matrix
[docs]
@staticmethod
def _symmetrization_symmetric(matrix):
"""
Helper function for symmetrization options
:type matrix: 2D numpy array
:arg matrix: pairwise ECA/ES scores of data
:rtype: 2D numpy array
:return: symmetrized matrix
"""
return matrix + matrix.T
[docs]
@staticmethod
def _symmetrization_antisym(matrix):
"""
Helper function for symmetrization options
:type matrix: 2D numpy array
:arg matrix: pairwise ECA/ES scores of data
:rtype: 2D numpy array
:return: antisymmetrized matrix
"""
return matrix - matrix.T
[docs]
@staticmethod
def _symmetrization_mean(matrix):
"""
Helper function for symmetrization options
:type matrix: 2D numpy array
:arg matrix: pairwise ECA/ES scores of data
:rtype: 2D numpy array
:return: symmetrized matrix using element-wise mean of matrix and
transposed matrix
"""
return np.mean([matrix, matrix.T], axis=0)
[docs]
@staticmethod
def _symmetrization_max(matrix):
"""
Helper function for symmetrization options
:type matrix: 2D numpy array
:arg matrix: pairwise ECA/ES scores of data
:rtype: 2D numpy array
:return: symmetrized matrix using element-wise maximum of matrix and
transposed matrix
"""
return np.maximum(matrix, matrix.T)
[docs]
@staticmethod
def _symmetrization_min(matrix):
"""
Helper function for symmetrization options
:type matrix: 2D numpy array
:arg matrix: pairwise ECA/ES scores of data
:rtype: 2D numpy array
:return: symmetrized matrix using element-wise minimum of matrix and
transposed matrix
"""
return np.minimum(matrix, matrix.T)
[docs]
@staticmethod
def make_event_matrix(data, threshold_method='quantile',
threshold_values=None, threshold_types=None):
"""
Create a binary event matrix from continuous time series data. Data
format is eventmatrix, i.e. a 2D numpy array with first dimension
covering time and second dimension covering the values of the
variables.
:type data: 2D numpy array
:arg data: Continuous input data
:type threshold_method: str 'quantile' or 'value' or 1D numpy array of
strings 'quantile' or 'value'
:arg threshold_method: specifies the method for generating a binary
event matrix from an array of continuous time
series. Default: 'quantile'
:type threshold_values: 1D Numpy array or float
:arg threshold_values: quantile or real number determining threshold
for each variable. Default: None.
:type threshold_types: str 'above' or 'below' or 1D list of strings
'above' or 'below'
:arg threshold_types: Determines for each variable if event is below or
above threshold
:rtype: 2D numpy array
:return: eventmatrix
"""
# Check correct format of event matrix
if not np.all([len(i) == len(data[0]) for i in data]):
warnings.warn("Data does not contain equal number of events")
data_axswap = np.swapaxes(data, 0, 1)
thresholds = np.zeros(data.shape[1])
# Check if inserted keyword arguments are correct and create parameter
# arrays in case only single keywords are used for data with more than
# one variable
threshold_method = np.array(threshold_method)
if threshold_method.shape == (data.shape[1],):
if not np.all([i in ['quantile', 'value'] for i in
threshold_method]):
raise IOError("'threshold_method' must be either 'quantile' or"
" 'value' or a 1D array-like object with"
" entries 'quantile' or 'value' for each"
" variable!")
elif not threshold_method.shape:
if threshold_method in ['quantile', 'value']:
threshold_method = np.array([threshold_method] * data.shape[1])
else:
raise IOError("'threshold_method' must be either 'quantile' or"
" 'value' or a 1D array-like object with entries"
" 'quantile' or 'value' for each variable!")
else:
raise IOError("'threshold_method' must be either 'quantile' or "
"'value' or a 1D array-like object with entries "
"'quantile' or 'value' for each variable!")
if threshold_values is not None:
threshold_values = np.array(threshold_values)
if threshold_values.shape == (data.shape[1],):
if not np.all([isinstance(i, (float, int))
for i in threshold_values]):
raise IOError("'threshold_values' must be either float/int"
" or 1D array-like object of float/int for "
" each variable!")
elif not threshold_values.shape:
if isinstance(threshold_values.item(), (int, float)):
threshold_values = \
np.array([threshold_values] * data.shape[1])
else:
raise IOError("'threshold_values' must be either float/int"
" or 1D array-like object of float/int for "
"each variable!")
else:
raise IOError("'threshold_values' must be either float/int or "
"1D array-like object of float/int for each "
"variable!")
else:
threshold_values = np.array([None] * data.shape[1])
warnings.warn("No 'threshold_values' given. Median is used by "
"default!")
if threshold_types is not None:
threshold_types = np.array(threshold_types)
if threshold_types.shape == (data.shape[1],):
if not np.all([i in ['above', 'below']
for i in threshold_types]):
raise IOError("'threshold_types' must be either 'above' or"
" 'below' or a 1D array-like object with "
"entries 'above' or 'below' for each "
"variable!")
elif not threshold_types.shape:
if threshold_types in ['above', 'below']:
threshold_types = \
np.array([threshold_types] * data.shape[1])
else:
raise IOError("'threshold_types' must be either 'above' or"
" 'below' or a 1D array-like object with "
"entries 'above' or 'below' for each "
"variable!")
else:
raise IOError("'threshold_types' must be either 'above' or "
"'below' or a 1D array-like object with entries "
"'above' or 'below' for each variable!")
else:
threshold_types = np.array([None] * data.shape[1])
warnings.warn("No 'threshold_types' given. If 'threshold_values' "
">= median, 'above' is used by default!")
# Go through threshold_method, threshold_value and threshold_type
# for each variable and check if input parameters are valid
# In case of missing input parameters, try to set default values
for i in range(data.shape[1]):
if threshold_method[i] == 'quantile':
# Check if threshold quantile is between zero and one
if threshold_values[i] is not None:
if threshold_values[i] > 1.0 or threshold_values[i] < 0.0:
raise ValueError("Threshold_value for threshold_method"
" 'quantile' must lie between 0.0 and"
" 1.0!")
# If threshold values are not given, use the median
else:
threshold_values[i] = 0.5
# Modification 2: Use nanquantile instead of quantile so that
# time series containing NaN values are handled gracefully
# rather than propagating NaN to the threshold computation.
thresholds[i] = \
np.nanquantile(data_axswap[i], threshold_values[i])
# If no threshold_types is given, check if threshold value is
# larger or equal median, then 'above'
if threshold_types[i] is None:
if threshold_values[i] >= 0.5:
threshold_types[i] = 'above'
else:
threshold_types[i] = 'below'
if threshold_method[i] == 'value':
if threshold_values[i] is None:
thresholds[i] = np.median(data_axswap[i])
else:
# Check if given threshold values lie within data range
if np.max(data_axswap[i]) < threshold_values[i] or \
np.min(data_axswap[i]) > threshold_values[i]:
raise IOError("Threshold_value for threshold_method "
"'value' must lie within variable "
"range!")
thresholds[i] = threshold_values[i]
if threshold_types[i] is None:
if thresholds[i] >= np.median(data_axswap[i]):
threshold_types[i] = 'above'
else:
threshold_types[i] = 'below'
# Other methods for thresholding can be easily added here
eventmatrix = np.zeros((data.shape[0], data.shape[1])) * (-1)
# Iterate through all variables of the data and create event matrix
# according to specified methods
for t in range(data.shape[0]):
for i in range(data.shape[1]):
if threshold_types[i] == 'above':
if data[t][i] > thresholds[i]:
eventmatrix[t][i] = 1
else:
eventmatrix[t][i] = 0
elif threshold_types[i] == 'below':
if data[t][i] < thresholds[i]:
eventmatrix[t][i] = 1
else:
eventmatrix[t][i] = 0
return eventmatrix
[docs]
@staticmethod
def event_synchronization(eventseriesx, eventseriesy, *,
ts1=None, ts2=None,
taumax=np.inf, lag=0.0):
"""
Calculates the directed event synchronization from two event series X
and Y using the algorithm described in [Quiroga2002]_,
[Odenweller2020]_
:type eventseriesx: 1D Numpy array
:arg eventseriesx: Event series containing '0's and '1's
:type eventseriesy: 1D Numpy array
:arg eventseriesy: Event series containing '0's and '1's
:type ts1: 1D Numpy array
:arg ts1: Event time array containing time points when events of event
series 1 occur, not obligatory
:type ts2: 1D Numpy array
:arg ts2: Event time array containing time points when events of event
series 2 occur, not obligatory
:type taumax: float
:arg taumax: maximum distance of two events to be counted as
synchronous
:type lag: float
:arg lag: delay between the two event series, the second event series
is shifted by the value of lag
:rtype: list
:return: [Event synchronization XY, Event synchronization YX]
"""
# Get time indices (type boolean or simple '0's and '1's)
# Careful here with datatype, int16 allows for maximum time index 32767
# Get time indices
if ts1 is None:
ex = np.array(np.where(eventseriesx), dtype='int16')
else:
ex = np.array([ts1[eventseriesx == 1]], dtype='float')
if ts2 is None:
ey = np.array(np.where(eventseriesy), dtype='int16')
else:
ey = np.array([ts2[eventseriesy == 1]], dtype='float')
ey = ey + lag
lx = ex.shape[1]
ly = ey.shape[1]
if lx == 0 or ly == 0: # Division by zero in output
return np.nan, np.nan
if lx in [1, 2] or ly in [1, 2]: # Too few events to calculate
return 0., 0.
# Array of distances
dstxy2 = 2 * (np.repeat(ex[:, 1:-1].T, ly - 2, axis=1)
- np.repeat(ey[:, 1:-1], lx - 2, axis=0))
# Dynamical delay
diffx = np.diff(ex)
diffy = np.diff(ey)
diffxmin = np.minimum(diffx[:, 1:], diffx[:, :-1])
diffymin = np.minimum(diffy[:, 1:], diffy[:, :-1])
tau2 = np.minimum(np.repeat(diffxmin.T, ly - 2, axis=1),
np.repeat(diffymin, lx - 2, axis=0))
tau2 = np.minimum(tau2, 2 * taumax)
# Count equal time events and synchronised events
eqtime = dstxy2.size - np.count_nonzero(dstxy2)
# Calculate boolean matrices of coincidences
Axy = (dstxy2 > 0) * (dstxy2 <= tau2)
Ayx = (dstxy2 < 0) * (dstxy2 >= -tau2)
# Loop over coincidences and determine number of double counts
# by checking at least one event of the pair is also coincided
# in other direction
countxydouble = countyxdouble = 0
for i, j in np.transpose(np.where(Axy)):
countxydouble += np.any(Ayx[i, :]) or np.any(Ayx[:, j])
for i, j in np.transpose(np.where(Ayx)):
countyxdouble += np.any(Axy[i, :]) or np.any(Axy[:, j])
# Calculate counting quantities and subtract half of double countings
countxy = np.sum(Axy) + 0.5 * eqtime - 0.5 * countxydouble
countyx = np.sum(Ayx) + 0.5 * eqtime - 0.5 * countyxdouble
norm = np.sqrt((lx - 2) * (ly - 2))
return countxy / norm, countyx / norm
[docs]
@staticmethod
def event_coincidence_analysis(eventseriesx, eventseriesy, taumax, *,
ts1=None, ts2=None, lag=0.0):
"""
Event coincidence analysis:
Returns the precursor and trigger coincidence rates of two event
series X and Y following the algorithm described in [Odenweller2020]_.
:type eventseriesx: 1D Numpy array
:arg eventseriesx: Event series containing '0's and '1's
:type eventseriesy: 1D Numpy array
:arg eventseriesy: Event series containing '0's and '1's
:type ts1: 1D Numpy array
:arg ts1: Event time array containing time points when events of event
series 1 occur, not obligatory
:type ts2: 1D Numpy array
:arg ts2: Event time array containing time points when events of event
series 2 occur, not obligatory
:type taumax: float
:arg taumax: coincidence interval width
:type lag: int
:arg lag: lag parameter
:rtype: list
:return: [Precursor coincidence rate XY, Trigger coincidence rate XY,
Precursor coincidence rate YX, Trigger coincidence rate YX]
"""
# Get time indices
if ts1 is None:
e1 = np.where(eventseriesx)[0]
else:
e1 = ts1[eventseriesx == 1]
if ts2 is None:
e2 = np.where(eventseriesy)[0]
else:
e2 = ts2[eventseriesy == 1]
# Count events that cannot be coincided due to lag and delT
if not (lag == 0 and taumax == 0):
n11 = len(e1[e1 <= e1[0] + lag + taumax]) # Start of es1
n12 = len(e1[e1 >= (e1[-1] - lag - taumax)]) # End of es1
n21 = len(e2[e2 <= e2[0] + lag + taumax]) # Start of es2
n22 = len(e2[e2 >= (e2[-1] - lag - taumax)]) # End of es2
else:
n11, n12, n21, n22 = 0, 0, 0, 0 # Instantaneous coincidence
# Number of events
l1 = len(e1)
l2 = len(e2)
# Array of all interevent distances
dst = (np.array([e1] * l2).T - np.array([e2] * l1))
# Count coincidences with array slicing
prec12 = np.count_nonzero(
np.any(((dst - lag >= 0) * (dst - lag <= taumax))[n11:, :],
axis=1))
trig12 = np.count_nonzero(
np.any(((dst - lag >= 0) * (dst - lag <= taumax))
[:, :dst.shape[1] - n22], axis=0))
prec21 = np.count_nonzero(np.any(((-dst - lag >= 0)
* (-dst - lag <= taumax))[:, n21:],
axis=0))
trig21 = np.count_nonzero(
np.any(((-dst - lag >= 0) * (-dst - lag <= taumax))
[:dst.shape[0] - n12, :], axis=1))
# Normalisation and output
return (np.float32(prec12) / (l1 - n11),
np.float32(trig12) / (l2 - n22),
np.float32(prec21) / (l2 - n21),
np.float32(trig21) / (l1 - n12))
[docs]
def _eca_coincidence_rate(self, eventseriesx, eventseriesy, *,
window_type='symmetric', ts1=None, ts2=None):
"""
Event coincidence analysis:
Returns the coincidence rates of two event series for both directions
:type eventseriesx: 1D Numpy array
:arg eventseriesx: Event series containing '0's and '1's
:type eventseriesy: 1D Numpy array
:arg eventseriesy: Event series containing '0's and '1's
:type ts1: 1D Numpy array
:arg ts1: Event time array containing time points when events of event
series 1 occur, not obligatory
:type ts2: 1D Numpy array
:arg ts2: Event time array containing time points when events of event
series 2 occur, not obligatory
:type window_type: str {'retarded', 'advanced', 'symmetric'}
:arg window_type: Only for ECA. Determines if precursor coincidence
rate ('advanced'), trigger coincidence rate
('retarded') or a general coincidence rate with the
symmetric interval [-taumax, taumax] are computed
('symmetric'). Default: 'symmetric'
:rtype: list
:return: Precursor coincidence rates [XY, YX]
"""
# Get time indices
if ts1 is None:
e1 = np.where(eventseriesx)[0]
else:
e1 = ts1[eventseriesx == 1]
if ts2 is None:
e2 = np.where(eventseriesy)[0]
else:
e2 = ts2[eventseriesy == 1]
lag = self.__lag
taumax = self.__taumax
# Number of events
l1 = len(e1)
l2 = len(e2)
# Array of all interevent distances
dst = (np.array([e1] * l2).T - np.array([e2] * l1))
if window_type == 'advanced':
deltaT1 = 0.0
deltaT2 = taumax
# Count events that cannot be coincided due to lag and deltaT
if not (lag == 0 and taumax == 0):
n11 = len(e1[e1 <= (e1[0] + lag + deltaT2)]) # Start of es1
n21 = len(e2[e2 <= (e2[0] + lag + deltaT2)]) # Start of es2
n12, n22 = 0, 0
else:
n11, n12, n21, n22 = 0, 0, 0, 0 # Instantaneous coincidence
# Count coincidences with array slicing
coincidence12 = np.count_nonzero(
np.any(((dst - lag >= deltaT1) * (dst - lag <= deltaT2))
[n11:, :], axis=1))
coincidence21 = np.count_nonzero(
np.any(((-dst - lag >= deltaT1) * (-dst - lag <= deltaT2))
[:, n21:], axis=0))
elif window_type == 'retarded':
deltaT1 = 0.0
deltaT2 = taumax
# Count events that cannot be coincided due to lag and delT
if not (lag == 0 and taumax == 0):
n11 = 0 # Start of es1
n12 = len(e1[e1 >= (e1[-1] - lag - deltaT2)]) # End of es1
n21 = 0 # Start of es2
n22 = len(e2[e2 >= (e2[-1] - lag - deltaT2)]) # End of es2
else:
n11, n12, n21, n22 = 0, 0, 0, 0 # Instantaneous coincidence
# Count coincidences with array slicing
coincidence12 = np.count_nonzero(
np.any(((dst - lag >= deltaT1) * (dst - lag <= deltaT2))
[:, :dst.shape[1] - n22], axis=0))
coincidence21 = np.count_nonzero(
np.any(((-dst - lag >= deltaT1) * (-dst - lag <= deltaT2))
[:dst.shape[0] - n12, :], axis=1))
return ((np.float32(coincidence12) / (l2 - n22),
np.float32(coincidence21) / (l1 - n12)))
elif window_type == 'symmetric':
deltaT1, deltaT2 = -taumax, taumax
# Count events that cannot be coincided due to lag and delT
if not (lag == 0 and taumax == 0):
n11 = len(e1[e1 <= (e1[0] + lag + deltaT2)]) # Start of es1
n12 = len(e1[e1 >= (e1[-1] - lag + deltaT1)]) # End of es1
n21 = len(e2[e2 <= (e2[0] + lag + deltaT2)]) # Start of es2
n22 = len(e2[e2 >= (e2[-1] - lag + deltaT1)]) # End of es2
else:
n11, n12, n21, n22 = 0, 0, 0, 0 # Instantaneous coincidence
# Count coincidences with array slicing
coincidence12 = np.count_nonzero(
np.any(((dst - lag >= deltaT1) * (dst - lag <= deltaT2))
[n11:dst.shape[0]-n12, :], axis=1))
coincidence21 = np.count_nonzero(
np.any(((-dst - lag >= deltaT1) * (-dst - lag <= deltaT2))
[:, n21:dst.shape[1]-n22], axis=0))
else:
raise IOError("Parameter 'window_type' must be 'advanced',"
" 'retarded' or 'symmetric'!")
# Normalisation and output
return (np.float32(coincidence12) / (l1 - n11 - n12),
np.float32(coincidence21) / (l2 - n21 - n22))
[docs]
def event_series_analysis(self, method='ES', symmetrization='directed',
window_type='symmetric'):
"""
Returns the NxN matrix of the chosen event series measure where N is
the number of variables. The entry [i, j] denotes the event
synchronization or event coincidence analysis from variable j to
variable i. According to the 'symmetrization' parameter the event
series measure matrix is symmetrized or not.
The event coincidence rate of ECA is calculated according to the
formula: r(Y|X, DeltaT1, DeltaT2, tau) =
1/N_X sum_{i=1}^{N_X} Theta[sum{j=1}^{N_Y}
1_[DeltaT1, DeltaT2] (t_i^X - (t_j^Y + tau))],
where X is the first input event series, Y the second input event
series, N_X the number of events in X, DeltaT1 and DeltaT2 the given
coincidence interval boundaries, tau the lag between X and Y, Theta the
Heaviside function and 1 the indicator function.
:type method: str 'ES' or 'ECA'
:arg method: determines if ES or ECA should be used
:type symmetrization: str {'directed', 'symmetric', 'antisym',
'mean', 'max', 'min'} for ES,
str {'directed', 'mean', 'max', 'min'} for ECA
:arg symmetrization: determines if and which symmetrisation
method should be used for the ES/ECA score matrix
:type window_type: str {'retarded', 'advanced', 'symmetric'}
:arg window_type: Only for ECA. Determines if precursor coincidence
rate ('advanced'), trigger coincidence rate
('retarded') or a general coincidence rate with the
symmetric interval [-taumax, taumax] are computed
('symmetric'). Default: 'symmetric'
:rtype: 2D numpy array
:return: pairwise event synchronization or pairwise coincidence rates
symmetrized according to input parameter 'symmetrization'
"""
if method not in ['ES', 'ECA']:
raise IOError("'method' parameter must be 'ECA' or 'ES'!")
directedESMatrix = []
if method == 'ES':
if symmetrization not in ['directed', 'symmetric', 'antisym',
'mean', 'max', 'min']:
raise IOError("'symmetrization' parameter must be 'directed', "
"'symmetric', 'antisym', 'mean', 'max' or"
"'min' for event synchronization!")
directedESMatrix = self._ndim_event_synchronization()
elif method == 'ECA':
if self.__taumax is np.inf:
raise ValueError("'delta' must be a finite time window to"
" determine event coincidence!")
if symmetrization not in ['directed', 'mean', 'max', 'min']:
raise IOError("'symmetrization' parameter must be 'directed', "
"'mean', 'max' or 'min' for event"
"coincidence analysis!")
if window_type not in ['retarded', 'advanced', 'symmetric']:
raise IOError("'window_type' must be 'retarded',"
"'advanced' or 'symmetric'!")
directedESMatrix = \
self._ndim_event_coincidence_analysis(window_type=window_type)
# Use symmetrization functions for symmetrization and return result
return self.symmetrization_options[symmetrization](directedESMatrix)
# =========================================================================
# ES — dispatches to the fastest available kernel (Optimizations 1 & 2)
# =========================================================================
[docs]
@Cached.method()
def _ndim_event_synchronization(self):
"""
Compute NxN event synchronization matrix [i,j] with event
synchronization from j to i without symmetrization.
Routing (Optimizations 1 & 2):
finite taumax, any lag → sparse binary-search (fast, exact)
infinite taumax, lag=0 → blocked-dense 4-D (vectorized)
infinite taumax, lag≠0 → pairwise loop (fallback)
:rtype: NxN numpy array where N is the number of variables of the
eventmatrix
:return: event synchronization matrix
"""
node_data = self._precompute_es_node_data()
if np.isfinite(self.__taumax):
# Optimization 1: sparse binary-search kernel
# (exact, O(N²·L·taumax/T))
return self._compute_es_matrix_sparse_lag(node_data)
else:
if self.__lag != 0.0:
# Fallback: pairwise loop (identical to original pyunicorn)
return self._ndim_event_synchronization_pairwise()
# Optimization 2: blocked dense 4-D tensor kernel
return self._compute_es_matrix_blocked(node_data)
[docs]
def _ndim_event_synchronization_pairwise(self):
"""
Pairwise ES loop — identical to original pyunicorn.
Used as fallback when taumax=inf and lag≠0.
Modification 3: tqdm progress bar added.
"""
N = self.__N
eventmatrix = self.__eventmatrix
timestamps = self.__timestamps
taumax = self.__taumax
lag = self.__lag
directed = np.zeros((N, N))
for i in tqdm(range(N), desc='ES pairwise'):
for j in range(i + 1, N):
directed[i, j], directed[j, i] = \
self.event_synchronization(eventmatrix[:, i],
eventmatrix[:, j],
ts1=timestamps, ts2=timestamps,
taumax=taumax, lag=lag)
return directed
[docs]
def _precompute_es_node_data(self):
"""
Optimization 1 / 2: Precompute inner-event times and per-event
dynamic tau2 for every node. Called once before the matrix loop.
"""
E = self.__eventmatrix
ts = self.__timestamps
taumax = self.__taumax
cap = 2.0 * taumax
node_data = []
for n in range(self.__N):
event_times = ts[E[:, n] > 0.5].astype(np.float64)
if len(event_times) <= 2:
inner_times = np.empty(0, dtype=np.float64)
tau2_inner = np.empty(0, dtype=np.float64)
else:
inner_times = event_times[1:-1].copy()
diffs = np.diff(event_times)
tau2_inner = np.minimum(diffs[:-1], diffs[1:])
tau2_inner = np.minimum(tau2_inner, cap)
node_data.append({
'event_times': event_times,
'inner_times': inner_times,
'tau2_inner': tau2_inner,
'inner_count': len(inner_times),
})
return node_data
[docs]
def _compute_es_matrix_blocked(self, node_data, block_size=32, n_jobs=1):
"""
Optimization 2: Assemble the full N×N directed ES matrix from blocked
4-D computations (infinite taumax, lag=0).
Optimization 4: n_jobs > 1 uses joblib if available.
"""
N = self.__N
directed = np.zeros((N, N), dtype=np.float64)
pairs = []
for a_start in range(0, N, block_size):
block_a = list(range(a_start, min(N, a_start + block_size)))
for b_start in range(a_start, N, block_size):
block_b = list(range(b_start, min(N, b_start + block_size)))
pairs.append((block_a, block_b))
if n_jobs != 1 and _JOBLIB_AVAILABLE:
results = Parallel(n_jobs=n_jobs)(
delayed(_compute_es_block)(node_data, ba, bb)
for ba, bb in pairs
)
else:
results = [
_compute_es_block(node_data, ba, bb)
for ba, bb in tqdm(pairs, desc='ES blocks')
]
for (block_a, block_b), (bxy, byx) in zip(pairs, results):
for ia, i in enumerate(block_a):
jb_start = ia + 1 if block_a[0] == block_b[0] else 0
for jb in range(jb_start, len(block_b)):
j = block_b[jb]
if i == j:
continue
directed[i, j] = bxy[ia, jb]
directed[j, i] = byx[ia, jb]
np.fill_diagonal(directed, 0.0)
return directed
[docs]
def _compute_es_matrix_sparse_lag(self, node_data, n_jobs=1):
"""
Optimization 1: Assemble the full N×N directed ES matrix using the
lag-aware sparse binary-search kernel. Works for any scalar lag value.
Row-based chunking minimises joblib serialisation overhead.
Optimization 4: n_jobs > 1 uses joblib if available.
"""
N = self.__N
taumax = self.__taumax
lag = self.__lag
directed = np.zeros((N, N), dtype=np.float64)
n_workers = n_jobs if (n_jobs != 1 and _JOBLIB_AVAILABLE) else 1
rows_per_job = max(1, -(-N // n_workers)) # ceiling division
row_chunks = [(s, min(N, s + rows_per_job))
for s in range(0, N, rows_per_job)]
if n_jobs != 1 and _JOBLIB_AVAILABLE:
results_list = Parallel(n_jobs=n_jobs)(
delayed(_process_es_rows_sparse_lag)(
r0, r1, N, node_data, taumax=taumax, lag=lag
)
for r0, r1 in row_chunks
)
else:
results_list = [
_process_es_rows_sparse_lag(
r0, r1, N, node_data, taumax=taumax, lag=lag
)
for r0, r1 in tqdm(row_chunks, desc='ES sparse (lag)',
unit='chunk')
]
for results in results_list:
for i, j, xy, yx in results:
directed[i, j] = xy
directed[j, i] = yx
np.fill_diagonal(directed, 0.0)
return directed
# =========================================================================
# ECA — dispatches to vectorized or pairwise kernel (Optimization 3)
# =========================================================================
[docs]
def _ndim_event_coincidence_analysis(self, window_type='symmetric'):
"""
Computes NxN event coincidence matrix of event coincidence rate.
Routing (Optimization 3):
uniform integer timestamps + integer lag → vectorized cumsum + BLAS
otherwise → pairwise loop (original)
:type window_type: str {'retarded', 'advanced', 'symmetric'}
:arg window_type: Only for ECA. Determines if precursor coincidence
rate ('advanced'), trigger coincidence rate
('retarded') or a general coincidence rate with the
symmetric interval [-taumax, taumax] are computed
('symmetric'). Default: 'symmetric'
:rtype: NxN numpy array where N is the number of variables of the
eventmatrix
:return: event coincidence matrix
"""
if window_type not in ['advanced', 'retarded', 'symmetric']:
raise IOError("'window_type' must be 'advanced', 'retarded' or"
" 'symmetric'!")
ts = self.__timestamps
dts = np.diff(ts)
is_uniform_integer = (len(dts) > 0
and np.allclose(dts, 1.0, rtol=1e-9, atol=1e-9))
is_integer_lag = np.isclose(self.__lag, round(self.__lag),
rtol=1e-9, atol=1e-9)
if is_uniform_integer and is_integer_lag:
# Optimization 3: vectorized cumsum + BLAS path
return self._compute_eca_vectorized_lag(window_type)
else:
# Fallback: pairwise loop (identical to original pyunicorn)
return self._compute_eca_pairwise(window_type)
[docs]
def _compute_eca_pairwise(self, window_type):
"""
Pairwise ECA loop — identical to original pyunicorn.
Modification 3: tqdm progress bar added.
"""
N = self.__N
eventmatrix = self.__eventmatrix
timestamps = self.__timestamps
directed = np.zeros((N, N))
for i in tqdm(range(N), desc=f'ECA pairwise ({window_type})'):
for j in range(i + 1, N):
directed[i, j], directed[j, i] = \
self._eca_coincidence_rate(eventmatrix[:, i],
eventmatrix[:, j],
window_type=window_type,
ts1=timestamps, ts2=timestamps)
return directed
[docs]
def _compute_eca_vectorized_lag(self, window_type):
"""
Optimization 3: Vectorized ECA via cumsum rolling windows + BLAS
matrix multiply, extended to support any integer-valued lag.
Core idea: build the lag=0 smeared indicator matrix with a cumsum
rolling window, then shift it by `lag` steps along the time axis.
Boundary corrections mirror the pairwise formula exactly.
For lag=0 the output is bit-for-bit identical to the pairwise path
within float32 rounding (atol < 1e-6 vs pairwise reference).
Complexity: O(T·N + N²) vs O(N²·L²) for the pairwise approach.
"""
E = self.__eventmatrix.astype(np.float64)
ts = self.__timestamps
T, N = E.shape
tau = int(round(self.__taumax))
lag = int(round(self.__lag))
N_events = E.sum(axis=0)
directed = np.zeros((N, N), dtype=np.float64)
def _shift(arr, shift):
"""Shift (T, N) array along axis=0 by `shift` rows (fill zeros)."""
if shift == 0:
return arr.copy()
out = np.zeros_like(arr)
if shift > 0:
rows = min(shift, T)
if rows < T:
out[rows:] = arr[:T - rows]
else:
rows = min(-shift, T)
if rows < T:
out[:T - rows] = arr[rows:]
return out
if tau == 0 and lag == 0:
tau_start = -1
tau_end = -1
else:
tau_start = tau + lag
tau_end = tau + lag
def _n_boundary_start(n, t_eff):
if N_events[n] == 0 or t_eff < 0:
return 0.0
ev = ts[E[:, n] > 0.5]
return float(np.sum(ev <= ev[0] + t_eff))
def _n_boundary_end(n, t_eff):
if N_events[n] == 0 or t_eff < 0:
return 0.0
ev = ts[E[:, n] > 0.5]
return float(np.sum(ev >= ev[-1] - t_eff))
def _first_ev_ts(n):
return ts[np.argmax(E[:, n] > 0.5)] if N_events[n] > 0 else ts[-1]
def _last_ev_ts(n):
return ts[T - 1 - np.argmax(E[::-1, n] > 0.5)] \
if N_events[n] > 0 else ts[0]
if window_type == 'advanced':
E_cs = np.cumsum(E, axis=0)
E_window = E_cs.copy()
if tau > 0:
E_window[tau + 1:] -= E_cs[:-tau - 1]
else:
E_window = E.copy()
E_smeared = (E_window > 0).astype(np.float64)
E_smeared = _shift(E_smeared, lag)
n_start = np.array([_n_boundary_start(n, tau_start)
for n in range(N)])
E_trimmed = E.copy()
for n in range(N):
if N_events[n] > 0 and tau_start >= 0:
E_trimmed[ts <= _first_ev_ts(n) + tau_start, n] = 0.0
adj_N = np.where(N_events - n_start > 0,
N_events - n_start, 1.0)
counts_f64 = E_trimmed.T @ E_smeared
adj_N_f32 = adj_N.astype(np.float32)
directed_f32 = counts_f64.astype(np.float32) / adj_N_f32[:, None]
directed = directed_f32.astype(np.float64)
np.fill_diagonal(directed, 0.0)
elif window_type == 'retarded':
E_rev = E[::-1]
E_cs_rev = np.cumsum(E_rev, axis=0)
E_win_rev = E_cs_rev.copy()
if tau > 0:
E_win_rev[tau + 1:] -= E_cs_rev[:-tau - 1]
else:
E_win_rev = E_rev.copy()
E_smeared_fwd = (E_win_rev[::-1] > 0).astype(np.float64)
E_smeared_fwd = _shift(E_smeared_fwd, -lag)
n_end = np.array([_n_boundary_end(n, tau_end)
for n in range(N)])
E_trimmed_fwd = E.copy()
for n in range(N):
if N_events[n] > 0 and tau_end >= 0:
E_trimmed_fwd[ts >= _last_ev_ts(n) - tau_end, n] = 0.0
adj_N = np.where(N_events - n_end > 0,
N_events - n_end, 1.0)
counts_f64 = E_smeared_fwd.T @ E_trimmed_fwd
adj_N_f32 = adj_N.astype(np.float32)
directed_f32 = counts_f64.astype(np.float32) / adj_N_f32[None, :]
directed = directed_f32.astype(np.float64)
np.fill_diagonal(directed, 0.0)
elif window_type == 'symmetric':
E_cs = np.cumsum(E, axis=0)
E_win_b = E_cs.copy()
if tau > 0:
E_win_b[tau + 1:] -= E_cs[:-tau - 1]
else:
E_win_b = E.copy()
E_rev = E[::-1]
E_cs_rev = np.cumsum(E_rev, axis=0)
E_win_f_r = E_cs_rev.copy()
if tau > 0:
E_win_f_r[tau + 1:] -= E_cs_rev[:-tau - 1]
else:
E_win_f_r = E_rev.copy()
E_win_f = E_win_f_r[::-1]
E_sym = E_win_b + E_win_f - E
E_smeared_sym = (E_sym > 0).astype(np.float64)
E_smeared_sym = _shift(E_smeared_sym, lag)
n_start = np.array([_n_boundary_start(n, tau_start)
for n in range(N)])
n_end = np.array([_n_boundary_end(n, tau_end)
for n in range(N)])
E_trimmed_sym = E.copy()
for n in range(N):
if N_events[n] > 0:
mask = np.zeros(T, dtype=bool)
if tau_start >= 0:
mask |= ts <= _first_ev_ts(n) + tau_start
if tau_end >= 0:
mask |= ts >= _last_ev_ts(n) - tau_end
E_trimmed_sym[mask, n] = 0.0
adj_N = np.where(N_events - n_start - n_end > 0,
N_events - n_start - n_end, 1.0)
counts_f64 = E_trimmed_sym.T @ E_smeared_sym
adj_N_f32 = adj_N.astype(np.float32)
directed_f32 = counts_f64.astype(np.float32) / adj_N_f32[:, None]
directed = directed_f32.astype(np.float64)
np.fill_diagonal(directed, 0.0)
return directed
# =========================================================================
# Significance analysis (identical to original pyunicorn)
# =========================================================================
[docs]
def _empirical_percentiles(self, method=None, n_surr=1000,
symmetrization='directed',
window_type='symmetric'):
"""
Compute p-values of event synchronisation (ES) and event coincidence
analysis (ECA) using a Monte-Carlo approach. Surrogates are obtained by
shuffling the event series. ES/ECA scores of the surrogate event series
are computed and p-values are the empirical percentiles of the original
event series compared to the ES/ECA scores of the surrogates.
:type method: str 'ES' or 'ECA'
:arg method: determines if ES or ECA should be used
:type n_surr: int
:arg n_surr: number of surrogates for Monte-Carlo method
:type symmetrization: str {'directed', 'symmetric', 'antisym',
'mean', 'max', 'min'} for ES,
str {'directed', 'mean', 'max', 'min'} for ECA
:arg symmetrization: determines if and which symmetrisation
method should be used for the ES/ECA score matrix
:type window_type: str {'retarded', 'advanced', 'symmetric'}
:arg window_type: Only for ECA. Determines if precursor coincidence
rate ('advanced'), trigger coincidence rate
('retarded') or a general coincidence rate with the
symmetric interval [-taumax, taumax] are computed
('symmetric'). Default: 'symmetric'
:rtype: 2D numpy array
:return: p-values of the ES/ECA scores for all
"""
# Get instance variables
lag = self.__lag
deltaT = self.__taumax
event_series_result = \
self.event_series_analysis(method=method,
symmetrization=symmetrization,
window_type=window_type)
surrogates = np.zeros((n_surr, self.__N, self.__N))
shuffled_es = self.__eventmatrix.copy()
# For each surrogate, shuffle each event series and perform ES/ECA
# analysis
for n in range(n_surr):
for i in range(self.__N):
np.random.shuffle(shuffled_es[:, i])
if method == 'ES':
for i in range(0, self.__N):
for j in range(i + 1, self.__N):
surrogates[n, i, j], surrogates[n, j, i] = \
self.event_synchronization(shuffled_es[:, i],
shuffled_es[:, j],
taumax=deltaT, lag=lag)
elif method == 'ECA':
for i in range(0, self.__N):
for j in range(i + 1, self.__N):
surrogates[n, i, j], surrogates[n, j, i] = \
self._eca_coincidence_rate(shuffled_es[:, i],
shuffled_es[:, j],
window_type=window_type)
# Symmetrize according to symmetry keyword argument
surrogates[n, :, :] = \
self.symmetrization_options[
symmetrization](surrogates[n, :, :])
# Calculate significance level via strict empirical percentiles for
# each event series pair
empirical_percentiles = np.zeros((self.__N, self.__N))
for i in range(self.__N):
for j in range(self.__N):
empirical_percentiles[i, j] = \
stats.percentileofscore(surrogates[:, i, j],
event_series_result[i][j],
kind='strict') / 100
return empirical_percentiles
[docs]
def event_analysis_significance(self, *, method=None,
surrogate='shuffle', n_surr=1000,
symmetrization='directed',
window_type='symmetric'):
"""
Returns significance levels (1 - p-values) for event synchronisation
(ES) and event coincidence analysis (ECA). For ECA, there is an
analytic option providing significance levels based on independent
Poisson processes. The 'shuffle' option uses a Monte-Carlo approach,
calculating ES or ECA scores for surrogate event time series obtained
by shuffling the original event time series. The significance levels
are the empirical percentiles of the ES/ECA scores of the original
event series compared with the scores of the surrogate data.
:type method: str 'ES' or 'ECA'
:arg method: determines if ES or ECA should be used
:type surrogate: str 'analytic' or 'shuffle'
:arg surrogate: determines if p-values should be calculated using a
Monte-Carlo method or (only for ECA) an analytic
Poisson process null model
:type n_surr: int
:arg n_surr: number of surrogates for Monte-Carlo method
:type symmetrization: str {'directed', 'symmetric', 'antisym',
'mean', 'max', 'min'} for ES,
str {'directed', 'mean', 'max', 'min'} for ECA
:arg symmetrization: determines if and which symmetrisation
method should be used for the ES/ECA score matrix
:type window_type: str {'retarded', 'advanced', 'symmetric'}
:arg window_type: Only for ECA. Determines if precursor coincidence
rate ('advanced'), trigger coincidence rate
('retarded') or a general coincidence rate with the
symmetric interval [-taumax, taumax] are computed
('symmetric'). Default: 'symmetric'
:rtype: 2D numpy array
:return: significance levels of the ES/ECA scores for all pairs of
event series in event matrix
"""
if method not in ['ES', 'ECA']:
raise IOError("'method' parameter must be 'ECA' or 'ES'!")
if surrogate not in ['analytic', 'shuffle']:
raise IOError("'surrogate' parameter must be 'analytic' or "
"'shuffle'!")
# Get instance variables
deltaT = self.__taumax
lag = self.__lag
if method == 'ECA':
if symmetrization not in ['directed', 'mean', 'max', 'min']:
raise IOError("'symmetrization' parameter must be 'directed', "
"'mean', 'max' or 'min' for event"
"coincidence analysis!")
if window_type not in ['retarded', 'advanced', 'symmetric']:
raise IOError("'window_type' must be 'retarded',"
"'advanced' or 'symmetric'!")
if surrogate == 'analytic':
if symmetrization != 'directed':
raise IOError("'symmetrization' parameter should be"
"'directed' for analytical calculation of"
"significance levels!")
if window_type not in ['retarded', 'advanced']:
raise IOError("'window_type' parameter must be 'retarded'"
" or 'advanced' for analytical computation"
" of significance levels!")
# Compute ECA scores of stored event matrix
directedECAMatrix = \
self._ndim_event_coincidence_analysis(
window_type=window_type)
significance_levels = np.zeros((self.__N, self.__N))
NEvents = self.__nrofevents
if window_type == 'advanced':
for i in range(self.__N):
for j in range(i + 1, self.__N):
# Compute Poisson probability p
p = deltaT / (float(self.__timestamps[-1]) - lag)
# Compute number of precursor coincidences 2->1
K12 = int(directedECAMatrix[i][j] * NEvents[i])
# Compute probability of at least K12 precursor
# events
pvalue = 0.0
for K_star in range(K12, NEvents[i] + 1):
pvalue += \
stats.binom.pmf(K_star, NEvents[i],
1.0 - pow(1.0 - p,
NEvents[j]))
significance_levels[i][j] = 1.0 - pvalue
# Compute number of precursor coincidence 1->2
K21 = int(directedECAMatrix[j][i] * NEvents[j])
# Compute probability of at least K21 precursor
# events
pvalue = 0.0
for K_star in range(K21, NEvents[j] + 1):
pvalue += \
stats.binom.pmf(K_star, NEvents[j],
1.0 - pow(1.0 - p,
NEvents[i]))
significance_levels[j][i] = 1.0 - pvalue
return significance_levels
# If window_type != 'advanced', it must be 'retarded'
else:
for i in range(self.__N):
for j in range(i + 1, self.__N):
p = deltaT / (float(self.__timestamps[-1]) - lag)
# Compute probability of at least K12 trigger
# events
# Compute number of trigger coincidence 2->1
K12 = int(directedECAMatrix[i][j] * NEvents[j])
# Compute Poisson probability p
pvalue = 0.0
for K_star in range(K12, NEvents[j]):
pvalue += \
stats.binom.pmf(K_star, NEvents[j],
1.0 - pow(1.0 - p,
NEvents[i]))
significance_levels[i][j] = 1.0 - pvalue
# Compute number of trigger coincidence 1->2
K21 = int(directedECAMatrix[j][i] * NEvents[i])
# Compute probability of at least K21 trigger
# events
pvalue = 0.0
for K_star in range(K21, NEvents[i]):
pvalue += \
stats.binom.pmf(K_star, NEvents[i],
1.0 - pow(1.0 - p,
NEvents[j]))
significance_levels[j][i] = 1.0 - pvalue
return significance_levels
# If surrogate is not 'analytic', it must be 'shuffle'
else:
return \
self._empirical_percentiles(method='ECA', n_surr=n_surr,
symmetrization=symmetrization,
window_type=window_type)
elif method == 'ES':
if surrogate != 'shuffle':
raise IOError("Analytical calculation of significance level is"
" only possible for event coincidence analysis!")
if symmetrization not in ['directed', 'symmetric', 'antisym',
'mean', 'max', 'min']:
raise IOError("'symmetrization' parameter must be 'directed', "
"'symmetric', 'antisym', 'mean', 'max' or"
"'min' for event synchronization!")
return \
self._empirical_percentiles(method='ES',
n_surr=n_surr,
symmetrization=symmetrization)
else:
return None