neurowarp 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- neurowarp-0.0.1/.gitignore +9 -0
- neurowarp-0.0.1/PKG-INFO +107 -0
- neurowarp-0.0.1/README.md +90 -0
- neurowarp-0.0.1/example_series_N2pcP3s.mat +0 -0
- neurowarp-0.0.1/pyproject.toml +28 -0
- neurowarp-0.0.1/src/neurowarp/__init__.py +2 -0
- neurowarp-0.0.1/src/neurowarp/latency_difference.py +481 -0
- neurowarp-0.0.1/src/neurowarp/timeseries_correlation.py +424 -0
neurowarp-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: neurowarp
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Toolbox for Dynamic Time Warping based latency differences and temporal correlations between two time series for neuroscience
|
|
5
|
+
Project-URL: Homepage, https://github.com/mahan-hosseini/NeuroWarp/
|
|
6
|
+
Project-URL: Issues, https://github.com/mahan-hosseini/NeuroWarp/Issues
|
|
7
|
+
Author-email: Mahan Hosseini <m.hosseini@fz-juelich.de>
|
|
8
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: matplotlib>=3.7
|
|
13
|
+
Requires-Dist: numpy>=1.24
|
|
14
|
+
Requires-Dist: scipy>=1.11
|
|
15
|
+
Requires-Dist: tslearn>=0.6.3
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# Python NeuroWarp
|
|
19
|
+
|
|
20
|
+
Python NeuroWarp is provided as a package that can be installed via PyPi. It consists of two functions that can be called after importing neurowarp: timeseries_correlation and latency_difference - these are the general-purpose scripts enable the DTW analyses presented in *Transient Attention Gates Access Consciousness: Coupling N2pc and P3 Latencies using Dynamic Time Warping*.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
We recommend that you create a new virtual environment for our module via:
|
|
25
|
+
1. Open a terminal / cmd.exe window, navigate (via `cd`) to the directory you want to create the environment and enter:
|
|
26
|
+
`python -m venv neurowarp_env`
|
|
27
|
+
2. Activate the neurowarp_env via:
|
|
28
|
+
|
|
29
|
+
**Windows:** `path\to\neurowarp_env\Scripts\activate`
|
|
30
|
+
|
|
31
|
+
**MacOS:** `source neurowarp_env/bin/activate`
|
|
32
|
+
|
|
33
|
+
3. Install neurowarp via pip (enter while neurowarp_env is active):
|
|
34
|
+
`pip install neurowarp`
|
|
35
|
+
4. The functions can be used as explained below after importing the module
|
|
36
|
+
|
|
37
|
+
For more detail on virtual environments & pip [click here](https://packaging.python.org/en/latest/guides/installing-using-pip-and-virtual-environments/)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
## DTW Temporal Correlation - neurowarp.timeseries_correlation()
|
|
41
|
+
Perform a **DTW based bootstrap analysis** to assess the **temporal correlation between two time series** (Figure 5 of our paper).
|
|
42
|
+
|
|
43
|
+
### Important Notes
|
|
44
|
+
- Time series must be **2D matrices**
|
|
45
|
+
- I.e., data points (e.g. time) x subjects (i.e., replications)
|
|
46
|
+
- We provide the ERPs of correct and intrusion trials for users to explore this function
|
|
47
|
+
|
|
48
|
+
### Running timeseries_correlation using our ERPs
|
|
49
|
+
*Enter the following into Python and make sure to enter your actual paths!*
|
|
50
|
+
1. `import neurowarp`
|
|
51
|
+
2. `from scipy.io import loadmat`
|
|
52
|
+
3. `data = loadmat("your/path/to/example_series_N2pcP3s")`
|
|
53
|
+
4. `series_1 = data["P3_Correct"]`
|
|
54
|
+
5. `series_2 = data["N2pc_Correct"]`
|
|
55
|
+
6. `name_1 = "P3"`
|
|
56
|
+
7. `name_2 = "N2pc"`
|
|
57
|
+
8. `savepath = "where/to/store/results/to"`
|
|
58
|
+
9. `num_boots = 10000`
|
|
59
|
+
- The number of bootstrap samples that you want to implement
|
|
60
|
+
10. `outlier = 0`
|
|
61
|
+
- Exclude outliers if their DTW area is +-5 standard deviations from the mean
|
|
62
|
+
11. `try_to_fix_ylims = 1`
|
|
63
|
+
- Attempt to standardise y-limits of marginals
|
|
64
|
+
12. `neurowarp.timeseries_correlation(series_1, series_2, name_1, name_2, savepath, num_boots, outlier, try_to_fix_ylims)`
|
|
65
|
+
|
|
66
|
+
*Note that the figure will look slightly different to that of our paper due to different x/y limits. See the replicate_figures folder if you want to replicate our figure as it was printed.*
|
|
67
|
+
|
|
68
|
+
## DTW Latency Difference - neurowarp.latency_difference()
|
|
69
|
+
Assess the **latency difference** between **two conditions** (i.e., within-subjects effect) or between **two groups** (i.e., across-subjects effect) of any signal of interest (in milliseconds).
|
|
70
|
+
|
|
71
|
+
*Figures 3 & 4 of our paper show a two conditions analysis*
|
|
72
|
+
|
|
73
|
+
### Important Notes
|
|
74
|
+
- Reference and query time series must be **2D matrices**
|
|
75
|
+
- I.e., data points (e.g., time) x subjects (i.e., replications)
|
|
76
|
+
- Time series have to be of **equal sizes**
|
|
77
|
+
- **analysis_design** determines whether you want to assess a within- or between-subjects latency effect (can only take “within” or “between” as input)
|
|
78
|
+
- We provide the ERPs of correct and intrusion trials for users to explore this function
|
|
79
|
+
|
|
80
|
+
### Running timeseries_correlation using our ERPs
|
|
81
|
+
*Enter the following into Python and make sure to enter your actual paths!*
|
|
82
|
+
1. `import neurowarp`
|
|
83
|
+
2. `from scipy.io import loadmat`
|
|
84
|
+
3. `data = loadmat("your/path/to/example_series_N2pcP3s")`
|
|
85
|
+
4. `analysis_design = "within"`
|
|
86
|
+
5. `query = data["N2pc_Intrusion"]`
|
|
87
|
+
6. `reference = data["N2pc_Correct"]`
|
|
88
|
+
7. `name_query = "Intrusion"`
|
|
89
|
+
8. `name_reference = "Correct"`
|
|
90
|
+
9. `units = "\u03BCV"`
|
|
91
|
+
- The units that your signals are measured in (in our case micro volts)
|
|
92
|
+
10. `sampling_rate = 500`
|
|
93
|
+
- The number of data points per second in Hertz
|
|
94
|
+
11. `savepath = "where/to/store/results/to"`
|
|
95
|
+
12. `permutations = 10000`
|
|
96
|
+
- The number of permutations you would like to implement in statistical testing (we recommend >=10000)
|
|
97
|
+
13. `neurowarp.latency_difference(analysis_design, query, reference, name_query, name_reference,units, sampling_rate, savepath, permutations)`
|
|
98
|
+
|
|
99
|
+
## Dependencies
|
|
100
|
+
*Python NeuroWarp requires the following toolboxes which are automatically installed via `pip install neurowarp`*
|
|
101
|
+
- Numpy
|
|
102
|
+
- Matplotlib
|
|
103
|
+
- Tslearn
|
|
104
|
+
- Scipy
|
|
105
|
+
|
|
106
|
+
## Tests
|
|
107
|
+
Python NeuroWarp was tested with Python 3.10.9.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Python NeuroWarp
|
|
2
|
+
|
|
3
|
+
Python NeuroWarp is provided as a package that can be installed via PyPi. It consists of two functions that can be called after importing neurowarp: timeseries_correlation and latency_difference - these are the general-purpose scripts enable the DTW analyses presented in *Transient Attention Gates Access Consciousness: Coupling N2pc and P3 Latencies using Dynamic Time Warping*.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
We recommend that you create a new virtual environment for our module via:
|
|
8
|
+
1. Open a terminal / cmd.exe window, navigate (via `cd`) to the directory you want to create the environment and enter:
|
|
9
|
+
`python -m venv neurowarp_env`
|
|
10
|
+
2. Activate the neurowarp_env via:
|
|
11
|
+
|
|
12
|
+
**Windows:** `path\to\neurowarp_env\Scripts\activate`
|
|
13
|
+
|
|
14
|
+
**MacOS:** `source neurowarp_env/bin/activate`
|
|
15
|
+
|
|
16
|
+
3. Install neurowarp via pip (enter while neurowarp_env is active):
|
|
17
|
+
`pip install neurowarp`
|
|
18
|
+
4. The functions can be used as explained below after importing the module
|
|
19
|
+
|
|
20
|
+
For more detail on virtual environments & pip [click here](https://packaging.python.org/en/latest/guides/installing-using-pip-and-virtual-environments/)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
## DTW Temporal Correlation - neurowarp.timeseries_correlation()
|
|
24
|
+
Perform a **DTW based bootstrap analysis** to assess the **temporal correlation between two time series** (Figure 5 of our paper).
|
|
25
|
+
|
|
26
|
+
### Important Notes
|
|
27
|
+
- Time series must be **2D matrices**
|
|
28
|
+
- I.e., data points (e.g. time) x subjects (i.e., replications)
|
|
29
|
+
- We provide the ERPs of correct and intrusion trials for users to explore this function
|
|
30
|
+
|
|
31
|
+
### Running timeseries_correlation using our ERPs
|
|
32
|
+
*Enter the following into Python and make sure to enter your actual paths!*
|
|
33
|
+
1. `import neurowarp`
|
|
34
|
+
2. `from scipy.io import loadmat`
|
|
35
|
+
3. `data = loadmat("your/path/to/example_series_N2pcP3s")`
|
|
36
|
+
4. `series_1 = data["P3_Correct"]`
|
|
37
|
+
5. `series_2 = data["N2pc_Correct"]`
|
|
38
|
+
6. `name_1 = "P3"`
|
|
39
|
+
7. `name_2 = "N2pc"`
|
|
40
|
+
8. `savepath = "where/to/store/results/to"`
|
|
41
|
+
9. `num_boots = 10000`
|
|
42
|
+
- The number of bootstrap samples that you want to implement
|
|
43
|
+
10. `outlier = 0`
|
|
44
|
+
- Exclude outliers if their DTW area is +-5 standard deviations from the mean
|
|
45
|
+
11. `try_to_fix_ylims = 1`
|
|
46
|
+
- Attempt to standardise y-limits of marginals
|
|
47
|
+
12. `neurowarp.timeseries_correlation(series_1, series_2, name_1, name_2, savepath, num_boots, outlier, try_to_fix_ylims)`
|
|
48
|
+
|
|
49
|
+
*Note that the figure will look slightly different to that of our paper due to different x/y limits. See the replicate_figures folder if you want to replicate our figure as it was printed.*
|
|
50
|
+
|
|
51
|
+
## DTW Latency Difference - neurowarp.latency_difference()
|
|
52
|
+
Assess the **latency difference** between **two conditions** (i.e., within-subjects effect) or between **two groups** (i.e., across-subjects effect) of any signal of interest (in milliseconds).
|
|
53
|
+
|
|
54
|
+
*Figures 3 & 4 of our paper show a two conditions analysis*
|
|
55
|
+
|
|
56
|
+
### Important Notes
|
|
57
|
+
- Reference and query time series must be **2D matrices**
|
|
58
|
+
- I.e., data points (e.g., time) x subjects (i.e., replications)
|
|
59
|
+
- Time series have to be of **equal sizes**
|
|
60
|
+
- **analysis_design** determines whether you want to assess a within- or between-subjects latency effect (can only take “within” or “between” as input)
|
|
61
|
+
- We provide the ERPs of correct and intrusion trials for users to explore this function
|
|
62
|
+
|
|
63
|
+
### Running timeseries_correlation using our ERPs
|
|
64
|
+
*Enter the following into Python and make sure to enter your actual paths!*
|
|
65
|
+
1. `import neurowarp`
|
|
66
|
+
2. `from scipy.io import loadmat`
|
|
67
|
+
3. `data = loadmat("your/path/to/example_series_N2pcP3s")`
|
|
68
|
+
4. `analysis_design = "within"`
|
|
69
|
+
5. `query = data["N2pc_Intrusion"]`
|
|
70
|
+
6. `reference = data["N2pc_Correct"]`
|
|
71
|
+
7. `name_query = "Intrusion"`
|
|
72
|
+
8. `name_reference = "Correct"`
|
|
73
|
+
9. `units = "\u03BCV"`
|
|
74
|
+
- The units that your signals are measured in (in our case micro volts)
|
|
75
|
+
10. `sampling_rate = 500`
|
|
76
|
+
- The number of data points per second in Hertz
|
|
77
|
+
11. `savepath = "where/to/store/results/to"`
|
|
78
|
+
12. `permutations = 10000`
|
|
79
|
+
- The number of permutations you would like to implement in statistical testing (we recommend >=10000)
|
|
80
|
+
13. `neurowarp.latency_difference(analysis_design, query, reference, name_query, name_reference,units, sampling_rate, savepath, permutations)`
|
|
81
|
+
|
|
82
|
+
## Dependencies
|
|
83
|
+
*Python NeuroWarp requires the following toolboxes which are automatically installed via `pip install neurowarp`*
|
|
84
|
+
- Numpy
|
|
85
|
+
- Matplotlib
|
|
86
|
+
- Tslearn
|
|
87
|
+
- Scipy
|
|
88
|
+
|
|
89
|
+
## Tests
|
|
90
|
+
Python NeuroWarp was tested with Python 3.10.9.
|
|
Binary file
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "neurowarp"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name="Mahan Hosseini", email="m.hosseini@fz-juelich.de" },
|
|
10
|
+
]
|
|
11
|
+
description = "Toolbox for Dynamic Time Warping based latency differences and temporal correlations between two time series for neuroscience"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"numpy>=1.24",
|
|
14
|
+
"matplotlib>=3.7",
|
|
15
|
+
"scipy>=1.11",
|
|
16
|
+
"tslearn>=0.6.3",
|
|
17
|
+
]
|
|
18
|
+
readme = "README.md"
|
|
19
|
+
requires-python = ">=3.10"
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/mahan-hosseini/NeuroWarp/"
|
|
28
|
+
Issues = "https://github.com/mahan-hosseini/NeuroWarp/Issues"
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
# %% ************ LATENCY DIFFERENCES WITH DYNAMIC TIME WARPING (IN MS) **************
|
|
2
|
+
|
|
3
|
+
# %% Imports
|
|
4
|
+
import pdb
|
|
5
|
+
import os
|
|
6
|
+
import pickle
|
|
7
|
+
import numpy as np
|
|
8
|
+
import matplotlib.pyplot as plt
|
|
9
|
+
from tslearn.metrics import dtw_path
|
|
10
|
+
import scipy.stats as stats
|
|
11
|
+
|
|
12
|
+
# %% Global constants
|
|
13
|
+
TITLE_FONTSIZE = 20
|
|
14
|
+
SUPLABEL_FONTSIZE = 15
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# %% Main function
|
|
18
|
+
def latency_difference(
|
|
19
|
+
analysis_design,
|
|
20
|
+
query,
|
|
21
|
+
reference,
|
|
22
|
+
name_query,
|
|
23
|
+
name_reference,
|
|
24
|
+
units,
|
|
25
|
+
sampling_rate,
|
|
26
|
+
savepath,
|
|
27
|
+
permutations,
|
|
28
|
+
):
|
|
29
|
+
"""
|
|
30
|
+
Conceptual Approach
|
|
31
|
+
-------------------
|
|
32
|
+
To assess a latency difference between 2 time series:
|
|
33
|
+
1) Average across subjects & compute DTW between the averages
|
|
34
|
+
2) The median of the distribution of the warping path's x/y distances
|
|
35
|
+
is used to compute the latency difference in milliseconds
|
|
36
|
+
3) The area between the warping path and the main diagonal is used
|
|
37
|
+
in a permutation procedure for assigning a p value to the latency
|
|
38
|
+
difference
|
|
39
|
+
|
|
40
|
+
Note
|
|
41
|
+
----
|
|
42
|
+
1) This function allows assessment of latency differences in within-
|
|
43
|
+
or between-subjects designs. Set the "analysis_design" input parameter
|
|
44
|
+
accordingly. This affects permutation procedure, adopting the
|
|
45
|
+
permutation equivalents of paired- or independent-sample t-tests
|
|
46
|
+
respectively.
|
|
47
|
+
2) This whole function is in line with the proposition of
|
|
48
|
+
Zoumpoulaki et al. (2015)
|
|
49
|
+
Latency as a region contrast: Measuring ERP latency differences with
|
|
50
|
+
Dynamic Time Warping
|
|
51
|
+
3) Your full time series are analysed.
|
|
52
|
+
If you would like to assess only a specific interval of your time
|
|
53
|
+
series, use indexing before calling this function.
|
|
54
|
+
|
|
55
|
+
Input Parameters
|
|
56
|
+
----------------
|
|
57
|
+
1) analysis_design
|
|
58
|
+
=> Character array or string informing about your analysis design
|
|
59
|
+
=> Has to be either "within" or "between", based on whether you have
|
|
60
|
+
a within- or between-subjects design
|
|
61
|
+
2) query
|
|
62
|
+
=> Query time series (matrix w. dimensions: datapoints x subjects)
|
|
63
|
+
3) reference
|
|
64
|
+
=> Reference time series (matrix w. dimensions: datapoints x subjects)
|
|
65
|
+
4) name_query
|
|
66
|
+
=> Name of the query time series
|
|
67
|
+
5) name_reference
|
|
68
|
+
=> Name of the reference time series
|
|
69
|
+
6) units
|
|
70
|
+
=> In what units was your data measured?
|
|
71
|
+
- For microvolts enter '\muV'
|
|
72
|
+
7) sampling_rate
|
|
73
|
+
=> What was the sampling rate of your signal? In Hertz
|
|
74
|
+
=> Used to compute the latency difference in milliseconds
|
|
75
|
+
8) savepath
|
|
76
|
+
=> Path with files
|
|
77
|
+
9) permutations
|
|
78
|
+
=> Number of permutations to adopt in statistical testing
|
|
79
|
+
- We recommend at least 10000
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
# %% Preparation
|
|
83
|
+
|
|
84
|
+
analysis_design = str(analysis_design)
|
|
85
|
+
units = str(units)
|
|
86
|
+
name_query = str(name_query)
|
|
87
|
+
name_reference = str(name_reference)
|
|
88
|
+
savepath = str(savepath)
|
|
89
|
+
plotpath = os.path.join(savepath, "Plots/")
|
|
90
|
+
varpath = os.path.join(savepath, "Variables/")
|
|
91
|
+
if not os.path.exists(plotpath):
|
|
92
|
+
os.makedirs(plotpath)
|
|
93
|
+
if not os.path.exists(varpath):
|
|
94
|
+
os.makedirs(varpath)
|
|
95
|
+
|
|
96
|
+
# Sanity check on analysis_design value
|
|
97
|
+
if analysis_design not in ["between", "within"]:
|
|
98
|
+
print('analysis_design must be either "between" or "within"! Fix & re-run')
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
# Sanity check if series are identical for within and have same length
|
|
102
|
+
try:
|
|
103
|
+
query = np.array(query)
|
|
104
|
+
reference = np.array(reference)
|
|
105
|
+
except:
|
|
106
|
+
raise ValueError(
|
|
107
|
+
"Query & Ref input vars must be convertible to np arrays - fix & rerun!"
|
|
108
|
+
)
|
|
109
|
+
# => can have different number of subjects if between
|
|
110
|
+
if analysis_design == "within":
|
|
111
|
+
if query.shape != reference.shape:
|
|
112
|
+
print(
|
|
113
|
+
"Note - query & reference don't have identical shapes - fix & re-run!"
|
|
114
|
+
)
|
|
115
|
+
return
|
|
116
|
+
else: # has to be 'between' now (
|
|
117
|
+
if query.shape[0] != reference.shape[0]:
|
|
118
|
+
print(
|
|
119
|
+
"Note - query & reference do not have same datapoint-length - fix & "
|
|
120
|
+
+ "re-run!"
|
|
121
|
+
)
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
# %% MAIN DTW COMPUTATION
|
|
125
|
+
# We need indices of x & y coordinates of warping path!
|
|
126
|
+
# ORIGINAL FORM: WP, score = dtw(x,y) (we don't need score)
|
|
127
|
+
# ==> QUERY IS ON X-AXIS OF WP, SO FIRST INPUT
|
|
128
|
+
# ==> REFERENCE IS ON Y-AXIS OF WP, SO SECOND INPUT!
|
|
129
|
+
# ==> WP is a list of lists with [ix, iy] coordinates of WP
|
|
130
|
+
# ==> WP of this will be below main diagonal if QUERY is
|
|
131
|
+
# LATER than REFERENCE
|
|
132
|
+
# ==> I.e. AREA will be positive in this case because area = areaDIAG -
|
|
133
|
+
# areaWP (and hence areaDIAG will be larger than areaWP if WP is below
|
|
134
|
+
# DIAG [and QUERY is indeed LATER than REFERENCE!]
|
|
135
|
+
|
|
136
|
+
# average across subjects
|
|
137
|
+
plot_avg_query = query.mean(axis=1)
|
|
138
|
+
plot_avg_reference = reference.mean(axis=1)
|
|
139
|
+
|
|
140
|
+
# DTW has to be done on standardised averages
|
|
141
|
+
z_avg_query = stats.zscore(plot_avg_query)
|
|
142
|
+
z_avg_reference = stats.zscore(plot_avg_reference)
|
|
143
|
+
WP, score = dtw_path(z_avg_query, z_avg_reference)
|
|
144
|
+
|
|
145
|
+
# extract indices of WP's x & y coordinates
|
|
146
|
+
i_query_x = []
|
|
147
|
+
i_reference_y = []
|
|
148
|
+
for WP_idxs in WP:
|
|
149
|
+
i_query_x.append(WP_idxs[0])
|
|
150
|
+
i_reference_y.append(WP_idxs[1])
|
|
151
|
+
warped_query = z_avg_query[i_query_x] # apply indices to get aligned time series
|
|
152
|
+
warped_reference = z_avg_reference[i_reference_y]
|
|
153
|
+
|
|
154
|
+
# plot standardised & aligned time series
|
|
155
|
+
plot_standardised_and_aligned_series(
|
|
156
|
+
z_avg_query, z_avg_reference, warped_query, warped_reference, units, plotpath
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Latency in MS is distribution of point-wise distances:
|
|
160
|
+
# (distance = ix - iy) / sampling rate (Hz) / 1000
|
|
161
|
+
distance = np.array(i_query_x) - np.array(i_reference_y)
|
|
162
|
+
latency = np.median(distance)
|
|
163
|
+
lat_in_ms = latency / (sampling_rate / 1000)
|
|
164
|
+
|
|
165
|
+
print("\n *** Latency Difference: %i ms! *** \n" % (lat_in_ms))
|
|
166
|
+
|
|
167
|
+
# Plot WP compared to main diagonal
|
|
168
|
+
plotWP(
|
|
169
|
+
i_query_x,
|
|
170
|
+
i_reference_y,
|
|
171
|
+
plot_avg_query,
|
|
172
|
+
plot_avg_reference,
|
|
173
|
+
name_query,
|
|
174
|
+
name_reference,
|
|
175
|
+
units,
|
|
176
|
+
lat_in_ms,
|
|
177
|
+
plotpath,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# %% PERMUTATION TEST ON AREA MEASURE
|
|
181
|
+
# ==> have this for median(distance), i.e., lat_in_ms_measure, too
|
|
182
|
+
|
|
183
|
+
# compute true observed area
|
|
184
|
+
xmax = i_query_x[-1] + 1 # because this is used with range
|
|
185
|
+
DIAG = np.arange(xmax)
|
|
186
|
+
areaDIAG = np.trapz(DIAG)
|
|
187
|
+
# IMPORTANT NOTE ABOUT NP.TRAPZ - TAKES IN (Y) & (Y,X) WHEREAS MATLAB'S TRAPZ
|
|
188
|
+
# TAKES (Y) & (X,Y) !!!!!
|
|
189
|
+
areaWP = np.trapz(i_reference_y, i_query_x)
|
|
190
|
+
area = (areaDIAG - areaWP) / areaDIAG
|
|
191
|
+
|
|
192
|
+
# prepare vars for permuting
|
|
193
|
+
original_query = query.copy() # copy so it's not just a reference
|
|
194
|
+
original_reference = reference.copy()
|
|
195
|
+
perm_x = [[] for _ in range(permutations)]
|
|
196
|
+
perm_y = [[] for _ in range(permutations)]
|
|
197
|
+
perm_area = [[] for _ in range(permutations)]
|
|
198
|
+
datapoints = np.shape(query)[0]
|
|
199
|
+
subjects = np.shape(query)[1]
|
|
200
|
+
|
|
201
|
+
# permutation loop
|
|
202
|
+
for perms in range(permutations):
|
|
203
|
+
# for a within-subject contrast, do the coin flipping for permuting class labels
|
|
204
|
+
if analysis_design == "within":
|
|
205
|
+
perm_query = np.zeros((datapoints, subjects))
|
|
206
|
+
perm_reference = np.zeros((datapoints, subjects))
|
|
207
|
+
for s in range(subjects):
|
|
208
|
+
coin = np.random.randint(1, 3, subjects) # 3 bc. indexing exclusive
|
|
209
|
+
if coin[s] == 1:
|
|
210
|
+
perm_query[:, s] = original_query[:, s]
|
|
211
|
+
perm_reference[:, s] = original_reference[:, s]
|
|
212
|
+
elif coin[s] == 2:
|
|
213
|
+
perm_query[:, s] = original_reference[:, s]
|
|
214
|
+
perm_reference[:, s] = original_query[:, s]
|
|
215
|
+
# for a between-subject contrast, randomly assign subjects to groups, preserving
|
|
216
|
+
# the original group sizes of query & reference
|
|
217
|
+
else:
|
|
218
|
+
subjects_query = np.shape(query)[1]
|
|
219
|
+
subjects_reference = np.shape(reference)[1]
|
|
220
|
+
subjects_total = subjects_query + subjects_reference
|
|
221
|
+
this_perm = np.random.permutation(subjects_total)
|
|
222
|
+
data_total = np.concatenate((query, reference), axis=1)
|
|
223
|
+
perm_query = data_total[:, this_perm[:subjects_query]]
|
|
224
|
+
perm_reference = data_total[:, this_perm[subjects_query:]]
|
|
225
|
+
|
|
226
|
+
# get averages across subjects
|
|
227
|
+
perm_query_avg = perm_query.mean(axis=1)
|
|
228
|
+
perm_reference_avg = perm_reference.mean(axis=1)
|
|
229
|
+
|
|
230
|
+
# DTW has to be done on standardised averages
|
|
231
|
+
perm_query_z_avg = stats.zscore(perm_query_avg)
|
|
232
|
+
perm_reference_z_avg = stats.zscore(perm_reference_avg)
|
|
233
|
+
perm_WP, perm_score = dtw_path(perm_query_z_avg, perm_reference_z_avg)
|
|
234
|
+
|
|
235
|
+
# extract indices of permuted WP's x & y coordinates - store in lists
|
|
236
|
+
perm_i_query_x = []
|
|
237
|
+
perm_i_reference_y = []
|
|
238
|
+
for WP_idxs in perm_WP:
|
|
239
|
+
perm_i_query_x.append(WP_idxs[0])
|
|
240
|
+
perm_i_reference_y.append(WP_idxs[1])
|
|
241
|
+
perm_x[perms] = perm_i_query_x
|
|
242
|
+
perm_y[perms] = perm_i_reference_y
|
|
243
|
+
|
|
244
|
+
# compute permuted area - store results in list
|
|
245
|
+
perm_xmax = perm_i_query_x[-1] + 1 # because this is used with range
|
|
246
|
+
perm_DIAG = np.arange(perm_xmax)
|
|
247
|
+
perm_areaDIAG = np.trapz(perm_DIAG)
|
|
248
|
+
# IMPORTANT NOTE ABOUT NP.TRAPZ - TAKES IN (Y) & (Y,X) WHEREAS MATLAB'S TRAPZ
|
|
249
|
+
# TAKES (Y) & (X,Y) !!!!!
|
|
250
|
+
perm_areaWP = np.trapz(perm_i_reference_y, perm_i_query_x)
|
|
251
|
+
perm_area[perms] = (perm_areaDIAG - perm_areaWP) / perm_areaDIAG
|
|
252
|
+
|
|
253
|
+
# permutation loop end
|
|
254
|
+
permdist_areas = np.sort(perm_area) # sort nonabsolutes for idxing thresholds
|
|
255
|
+
sort_abs_permdist_areas = np.sort(abs(permdist_areas))
|
|
256
|
+
thresh_abs = round(len(permdist_areas) * 0.95)
|
|
257
|
+
# find 5% sig. thresh using sorted absolutes
|
|
258
|
+
thresh_abs_val = sort_abs_permdist_areas[thresh_abs]
|
|
259
|
+
thresh1 = -thresh_abs_val
|
|
260
|
+
thresh2 = thresh_abs_val
|
|
261
|
+
|
|
262
|
+
# p value (two tailed test)
|
|
263
|
+
p = sum(abs(permdist_areas) >= abs(area)) / permutations
|
|
264
|
+
if p < 0.0001:
|
|
265
|
+
plot_p = "p < 0.0001"
|
|
266
|
+
else:
|
|
267
|
+
plot_p = "p = " + str(round(p, 3))
|
|
268
|
+
print("\n *** %s *** \n" % (plot_p))
|
|
269
|
+
|
|
270
|
+
# Plot figure of observed area-size & its permutation distribution
|
|
271
|
+
f, ax = plt.subplots()
|
|
272
|
+
plt.tight_layout()
|
|
273
|
+
ax.hist(permdist_areas, bins=30, facecolor="#c7fdb5", edgecolor="#e4cbff")
|
|
274
|
+
ax.axvline(thresh1, linewidth=1.5, linestyle="-", color="#ff796c")
|
|
275
|
+
ax.axvline(thresh2, linewidth=1.5, linestyle="-", color="#ff796c")
|
|
276
|
+
ax.axvline(area, linewidth=1.5, linestyle="-", color="#8ab8fe")
|
|
277
|
+
yticks = ax.get_yticks()
|
|
278
|
+
yticklabels_num = yticks / permutations
|
|
279
|
+
yticklabels_str = [str(tick) for tick in yticklabels_num]
|
|
280
|
+
ax.set_yticks(yticks, labels=yticklabels_str)
|
|
281
|
+
ax.set_ylabel("Proportion", fontsize=12)
|
|
282
|
+
ax.set_xlabel("DTW - Area", fontsize=12)
|
|
283
|
+
ax.set_title(
|
|
284
|
+
"Latency diff.: %i ms - %s" % (lat_in_ms, plot_p),
|
|
285
|
+
fontweight="bold",
|
|
286
|
+
fontsize=13,
|
|
287
|
+
)
|
|
288
|
+
f.savefig(
|
|
289
|
+
os.path.join(plotpath, "Permutation Distribution.png"),
|
|
290
|
+
bbox_inches="tight",
|
|
291
|
+
dpi=300,
|
|
292
|
+
)
|
|
293
|
+
f.savefig(
|
|
294
|
+
os.path.join(plotpath, "Permutation Distribution.svg"),
|
|
295
|
+
bbox_inches="tight",
|
|
296
|
+
dpi=300,
|
|
297
|
+
)
|
|
298
|
+
plt.show(block=False)
|
|
299
|
+
plt.pause(0.001)
|
|
300
|
+
|
|
301
|
+
# Plot figure of observed area-size & its permutation distribution
|
|
302
|
+
f, ax = plt.subplots()
|
|
303
|
+
plt.tight_layout()
|
|
304
|
+
ax.hist(abs(permdist_areas), bins=30, facecolor="#c7fdb5", edgecolor="#e4cbff")
|
|
305
|
+
ax.axvline(thresh_abs_val, linewidth=1.5, linestyle="-", color="#ff796c")
|
|
306
|
+
ax.axvline(abs(area), linewidth=1.5, linestyle="-", color="#8ab8fe")
|
|
307
|
+
yticks = ax.get_yticks()
|
|
308
|
+
yticklabels_num = yticks / permutations
|
|
309
|
+
yticklabels_str = [str(tick) for tick in yticklabels_num]
|
|
310
|
+
ax.set_yticks(yticks, labels=yticklabels_str)
|
|
311
|
+
ax.set_ylabel("Proportion", fontsize=12)
|
|
312
|
+
ax.set_xlabel("DTW - Area (absolute)", fontsize=12)
|
|
313
|
+
ax.set_title(
|
|
314
|
+
"Absolute Areas - Latency diff.: %i ms - %s" % (lat_in_ms, plot_p),
|
|
315
|
+
fontweight="bold",
|
|
316
|
+
fontsize=13,
|
|
317
|
+
)
|
|
318
|
+
f.savefig(
|
|
319
|
+
os.path.join(plotpath, "Permutation Distribution (absolutes).png"),
|
|
320
|
+
bbox_inches="tight",
|
|
321
|
+
dpi=300,
|
|
322
|
+
)
|
|
323
|
+
f.savefig(
|
|
324
|
+
os.path.join(plotpath, "Permutation Distribution (absolutes).svg"),
|
|
325
|
+
bbox_inches="tight",
|
|
326
|
+
dpi=300,
|
|
327
|
+
)
|
|
328
|
+
plt.show(block=False)
|
|
329
|
+
plt.pause(0.001)
|
|
330
|
+
|
|
331
|
+
# store variables to a pickle file
|
|
332
|
+
vars_to_save = {
|
|
333
|
+
"area": area,
|
|
334
|
+
"p": p,
|
|
335
|
+
"thresh1": thresh1,
|
|
336
|
+
"thresh2": thresh2,
|
|
337
|
+
"perm_x": perm_x,
|
|
338
|
+
"perm_y": perm_y,
|
|
339
|
+
"permdist_areas": permdist_areas,
|
|
340
|
+
}
|
|
341
|
+
picklepath = os.path.join(
|
|
342
|
+
varpath, ("DTW Permutation Test - %s subjects.pkl" % analysis_design)
|
|
343
|
+
)
|
|
344
|
+
with open(picklepath, "wb") as file:
|
|
345
|
+
pickle.dump(vars_to_save, file)
|
|
346
|
+
print(
|
|
347
|
+
"\n\n****************************"
|
|
348
|
+
+ "\nDTW Latency Analysis is done"
|
|
349
|
+
+ "\n****************************\n\nResults were saved to:\n"
|
|
350
|
+
+ savepath
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# %% Local functions - plotting
|
|
355
|
+
def plot_standardised_and_aligned_series(
|
|
356
|
+
z_avg_query, z_avg_reference, warped_query, warped_reference, units, plotpath
|
|
357
|
+
):
|
|
358
|
+
"""Plots a figure of original/standardised and aligned time series"""
|
|
359
|
+
f, ax = plt.subplots(4, 1)
|
|
360
|
+
plt.tight_layout()
|
|
361
|
+
plt.subplots_adjust(left=0.1, bottom=0.1, top=0.9)
|
|
362
|
+
ax[0].plot(z_avg_query, color="k") # plot the data
|
|
363
|
+
ax[1].plot(z_avg_reference, color="r")
|
|
364
|
+
ax[2].plot(warped_query, color="k")
|
|
365
|
+
ax[3].plot(warped_reference, color="r")
|
|
366
|
+
# ylims of standardised time series
|
|
367
|
+
stand_min = min([z_avg_query.min(), z_avg_reference.min(), -1])
|
|
368
|
+
stand_max = max([z_avg_query.max(), z_avg_reference.max(), 1])
|
|
369
|
+
ax[0].set_ylim([stand_min, stand_max])
|
|
370
|
+
ax[1].set_ylim([stand_min, stand_max])
|
|
371
|
+
# ylims of warped time series
|
|
372
|
+
warped_min = min([warped_query.min(), warped_reference.min(), -1])
|
|
373
|
+
warped_max = max([warped_query.max(), warped_reference.max(), -1])
|
|
374
|
+
ax[2].set_ylim([warped_min, warped_max])
|
|
375
|
+
ax[3].set_ylim([warped_min, warped_max])
|
|
376
|
+
# labels, titles & save
|
|
377
|
+
ax[-1].set_xlabel("Datapoints", fontsize=SUPLABEL_FONTSIZE)
|
|
378
|
+
f.suptitle("Alignment", y=0.99, fontsize=TITLE_FONTSIZE)
|
|
379
|
+
f.supylabel(units + " (z-scored)", x=0.01, fontsize=SUPLABEL_FONTSIZE)
|
|
380
|
+
f.savefig(os.path.join(plotpath, "Alignment.png"), bbox_inches="tight", dpi=300)
|
|
381
|
+
f.savefig(os.path.join(plotpath, "Alignment.svg"), bbox_inches="tight", dpi=300)
|
|
382
|
+
plt.show(block=False) # otherwise stopped further code if python via terminal
|
|
383
|
+
plt.pause(0.001)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def plotWP(
|
|
387
|
+
i_query_x,
|
|
388
|
+
i_reference_y,
|
|
389
|
+
plot_avg_query,
|
|
390
|
+
plot_avg_reference,
|
|
391
|
+
name_query,
|
|
392
|
+
name_reference,
|
|
393
|
+
units,
|
|
394
|
+
lat_in_ms,
|
|
395
|
+
plotpath,
|
|
396
|
+
):
|
|
397
|
+
"""Plot the warping path, the main diagonal and both original time series"""
|
|
398
|
+
f, ax_global = plt.subplots()
|
|
399
|
+
ax_global.set_xticks([])
|
|
400
|
+
ax_global.set_yticks([])
|
|
401
|
+
gs = f.add_gridspec(4, 4) # prepare the grid
|
|
402
|
+
plt.tight_layout()
|
|
403
|
+
plt.subplots_adjust(left=0.1, bottom=0.1, top=0.9, hspace=0.5)
|
|
404
|
+
|
|
405
|
+
# *** grid space 1: WP & main diagonal ***
|
|
406
|
+
# main diagonal
|
|
407
|
+
ax = f.add_subplot(gs[0:3, 1:4]) # set wanted gridcells as ax
|
|
408
|
+
ax.set_xlim(right=i_query_x[-1])
|
|
409
|
+
ax.set_ylim(top=i_reference_y[-1])
|
|
410
|
+
xmax = i_query_x[-1] + 1 # bc. indexing starts at 0 & we use this var only w. range
|
|
411
|
+
ax.plot(
|
|
412
|
+
np.arange(xmax), np.arange(xmax), color="#ff796c", linewidth=1.5
|
|
413
|
+
) # c==salmon
|
|
414
|
+
# warping path
|
|
415
|
+
ax.plot(i_query_x, i_reference_y, color="#8ab8fe", linewidth=1.5) # c==carolinablue
|
|
416
|
+
# fill area between WP & main diagonal
|
|
417
|
+
ax.fill_between(
|
|
418
|
+
i_query_x, i_query_x, i_reference_y, color="#c7fdb5", alpha=0.5, edgecolor="k"
|
|
419
|
+
)
|
|
420
|
+
# remove ticklabels
|
|
421
|
+
ax.set_xticklabels([])
|
|
422
|
+
ax.set_yticklabels([])
|
|
423
|
+
|
|
424
|
+
# *** grid space 2: query on x axis ***
|
|
425
|
+
ax_2 = f.add_subplot(gs[-1, 1:])
|
|
426
|
+
ax_2.plot(plot_avg_query, color="r")
|
|
427
|
+
|
|
428
|
+
# *** grid space 3: reference on y axis ***
|
|
429
|
+
ax_3 = f.add_subplot(gs[0:-1, 0])
|
|
430
|
+
# IMPORTANT NOTE - plt does not have camroll as matlab does. Thus we:
|
|
431
|
+
# 1. plot reference's y values (e.g. in mV) on "x axis"
|
|
432
|
+
# 2. plot len of idxs (xmax) as "y values"
|
|
433
|
+
# 3. we now HAVE TO invert the xaxis to get the same effect as with camroll
|
|
434
|
+
ax_3.plot(plot_avg_reference, np.arange(xmax), color="k")
|
|
435
|
+
ax_3.invert_xaxis()
|
|
436
|
+
|
|
437
|
+
# axis stuff
|
|
438
|
+
ylimmax = max([plot_avg_query.max(), plot_avg_reference.max()])
|
|
439
|
+
ylimmax = ylimmax + (ylimmax * 0.1)
|
|
440
|
+
ax_2.set_ylim(top=ylimmax)
|
|
441
|
+
ax_3.set_ylim(top=ylimmax)
|
|
442
|
+
xticks = ax.get_xticks() # set xaxis of ax_2 and yaxis of ax_3 according to WP axis
|
|
443
|
+
xlims = ax.get_xlim()
|
|
444
|
+
ax_2.set_xticks(xticks)
|
|
445
|
+
ax_2.set_xlim(xlims)
|
|
446
|
+
ax_3.set_yticks(xticks)
|
|
447
|
+
ax_3.set_ylim(xlims)
|
|
448
|
+
# query & reference text & labels
|
|
449
|
+
ax_2.text(
|
|
450
|
+
0.77,
|
|
451
|
+
1.1,
|
|
452
|
+
name_query,
|
|
453
|
+
transform=ax_2.transAxes,
|
|
454
|
+
fontsize=12,
|
|
455
|
+
fontweight="bold",
|
|
456
|
+
fontstyle="italic",
|
|
457
|
+
)
|
|
458
|
+
ax_3.text(
|
|
459
|
+
0.2,
|
|
460
|
+
1.03,
|
|
461
|
+
name_reference,
|
|
462
|
+
transform=ax_3.transAxes,
|
|
463
|
+
fontsize=12,
|
|
464
|
+
fontweight="bold",
|
|
465
|
+
fontstyle="italic",
|
|
466
|
+
)
|
|
467
|
+
ax_2.set_ylabel(units, labelpad=1)
|
|
468
|
+
ax_3.set_xlabel(units, labelpad=1)
|
|
469
|
+
for spine in ax_global.spines.values(): # despine outmost axis
|
|
470
|
+
spine.set_visible(False)
|
|
471
|
+
|
|
472
|
+
# suplabels & save the figure
|
|
473
|
+
ax.set_title(
|
|
474
|
+
"Latency difference: %i ms" % lat_in_ms, fontweight="bold", fontsize=13
|
|
475
|
+
)
|
|
476
|
+
ax_2.set_xlabel("Datapoints", fontsize=11)
|
|
477
|
+
ax_3.set_ylabel("Datapoints", fontsize=11)
|
|
478
|
+
f.savefig(os.path.join(plotpath, "WarpPath.png"), bbox_inches="tight", dpi=300)
|
|
479
|
+
f.savefig(os.path.join(plotpath, "WarpPath.svg"), bbox_inches="tight", dpi=300)
|
|
480
|
+
plt.show(block=False)
|
|
481
|
+
plt.pause(0.001)
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
# %% ************* CORRELATE TWO TIME SERIES WITH DYNAMIC TIME WARPING ***************
|
|
2
|
+
|
|
3
|
+
# %% Imports
|
|
4
|
+
import pdb
|
|
5
|
+
import os
|
|
6
|
+
import pickle
|
|
7
|
+
import numpy as np
|
|
8
|
+
import matplotlib.pyplot as plt
|
|
9
|
+
from tslearn.metrics import dtw_path
|
|
10
|
+
import scipy.stats as stats
|
|
11
|
+
|
|
12
|
+
# %% Global constants
|
|
13
|
+
TITLE_FONTSIZE = 20
|
|
14
|
+
SUPLABEL_FONTSIZE = 15
|
|
15
|
+
INFO_TXT_FILENAME = "CorrelateAnalysis_Info.txt"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# %% Main function
|
|
19
|
+
def timeseries_correlation(
|
|
20
|
+
series_1, series_2, name_1, name_2, savepath, num_boots, outlier, try_to_fix_ylims
|
|
21
|
+
):
|
|
22
|
+
"""
|
|
23
|
+
Conceptual Approach
|
|
24
|
+
-------------------
|
|
25
|
+
To assess latency correlations between 2 time series:
|
|
26
|
+
1) Get Single Subject (or group) time series
|
|
27
|
+
2) Get N bootstrap time series (w. same bootstrapping
|
|
28
|
+
subject-distributions for the two time series)
|
|
29
|
+
3) DTW of 2) with 1) using AREA measure!
|
|
30
|
+
4) Gives you two numbers for each bootstrap sample -> DTW result of
|
|
31
|
+
time series 1 & 2 latency (lag)
|
|
32
|
+
5) Correlate these with Pearson and Spearman (latter preferred for
|
|
33
|
+
presentations)
|
|
34
|
+
|
|
35
|
+
Input Parameters
|
|
36
|
+
----------------
|
|
37
|
+
1) series_1, series_2
|
|
38
|
+
=> Timeseries as two matrices of shape:
|
|
39
|
+
dataindices (e.g., timepoints) x subjects (or repetitions)
|
|
40
|
+
2) name_1, name_2
|
|
41
|
+
=> Names of your timeseries
|
|
42
|
+
3) savepath
|
|
43
|
+
=> Where to save output to
|
|
44
|
+
4) num_boots
|
|
45
|
+
=> Number of bootstraps (we recommend at least 10000)
|
|
46
|
+
5) outlier
|
|
47
|
+
=> 1: to exclude +-5SD outliers from your scatterplot
|
|
48
|
+
=> 0: don't exclude outliers
|
|
49
|
+
6) try_to_fix_ylims
|
|
50
|
+
=> 1: try to fix ylimits of marginals and include lines for easier
|
|
51
|
+
comparison
|
|
52
|
+
- based on num_boots
|
|
53
|
+
- if you want to modify this yourself (e.g., hardcode lines &
|
|
54
|
+
maxima, change the if statement blocks for the two marginals)
|
|
55
|
+
=> 0: let matlab handle ylimits of marginals (likely won't allow
|
|
56
|
+
comparisons
|
|
57
|
+
|
|
58
|
+
Note
|
|
59
|
+
----
|
|
60
|
+
1) Number of subjects has to be equal in both time series!
|
|
61
|
+
2) Your full time series are analysed.
|
|
62
|
+
If you would like to assess only a specific interval of your time
|
|
63
|
+
series, use indexing before calling this function.
|
|
64
|
+
3) We fix marginals' x-axis ranges to +-8 SD. This should be okay for
|
|
65
|
+
most data-sets since we zscore DTW area distributions. However, if you
|
|
66
|
+
do not want to standardise like this (which will make your marginals
|
|
67
|
+
harder to compare) or you want to implement different values, change
|
|
68
|
+
the lines that use xlimits & ylimits (see line 180)
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
# %% Preparation
|
|
72
|
+
|
|
73
|
+
# first check if user tried to compare groups with unequal sample sizes
|
|
74
|
+
if np.shape(series_1)[1] != np.shape(series_2)[1]:
|
|
75
|
+
print("Your time series have a different number of subjects - cancelling!")
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
# prepare some vars
|
|
79
|
+
name_1 = str(name_1)
|
|
80
|
+
name_2 = str(name_2)
|
|
81
|
+
plotpath = os.path.join(savepath, "Plots/")
|
|
82
|
+
varpath = os.path.join(savepath, "Variables/")
|
|
83
|
+
if not os.path.exists(plotpath):
|
|
84
|
+
os.makedirs(plotpath)
|
|
85
|
+
if not os.path.exists(varpath):
|
|
86
|
+
os.makedirs(varpath)
|
|
87
|
+
DTW_marginals = {"name_1": [], "name_2": []}
|
|
88
|
+
DTW_stats = dict()
|
|
89
|
+
|
|
90
|
+
# %% Run the analysis
|
|
91
|
+
series_1_dtw_area, series_2_dtw_area = run_bootstrap_dtw(
|
|
92
|
+
series_1, series_2, num_boots
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# %% Outlier Rejection
|
|
96
|
+
if outlier:
|
|
97
|
+
|
|
98
|
+
# exclude outliers if +-5 SD (change in std_thresh line if wanted)
|
|
99
|
+
series_1_std = np.std(series_1_dtw_area)
|
|
100
|
+
series_2_std = np.std(series_2_dtw_area)
|
|
101
|
+
std_thresh = 5 # Threshold for outlier rejection
|
|
102
|
+
|
|
103
|
+
# *****************************************************************
|
|
104
|
+
# IMPORTANT
|
|
105
|
+
# This might seem like a strange way of removing outliers
|
|
106
|
+
# ==> However it ensures that we check all 4 directions of the
|
|
107
|
+
# scatterplot for outliers, save indices as we do so, then just
|
|
108
|
+
# take those unique indices (removing duplicates) and (IMPORTANTLY)
|
|
109
|
+
# IN THE END removing these indices FROM BOTH DTW distributions
|
|
110
|
+
# ==> THIS IS IMPORTANT BECAUSE DTW DISTRIBUTIONS HAVE TO STAY
|
|
111
|
+
# LINKED DUE TO THE BOOTSTRAP
|
|
112
|
+
# *****************************************************************
|
|
113
|
+
|
|
114
|
+
outlier_idx = []
|
|
115
|
+
# series 1 - positive outliers
|
|
116
|
+
series_1_pos_outlier_idx = np.where(
|
|
117
|
+
series_1_dtw_area
|
|
118
|
+
> (np.mean(series_1_dtw_area) + (std_thresh * series_1_std))
|
|
119
|
+
)[0]
|
|
120
|
+
if len(series_1_pos_outlier_idx) > 0:
|
|
121
|
+
outlier_idx.extend(series_1_pos_outlier_idx)
|
|
122
|
+
# series 1 - negative outliers
|
|
123
|
+
series_1_neg_outlier_idx = np.where(
|
|
124
|
+
series_1_dtw_area
|
|
125
|
+
< (np.mean(series_1_dtw_area) - (std_thresh * series_1_std))
|
|
126
|
+
)[0]
|
|
127
|
+
if len(series_1_neg_outlier_idx) > 0:
|
|
128
|
+
outlier_idx.extend(series_1_neg_outlier_idx)
|
|
129
|
+
# series 2 - positive outliers
|
|
130
|
+
series_2_pos_outlier_idx = np.where(
|
|
131
|
+
series_2_dtw_area
|
|
132
|
+
> (np.mean(series_2_dtw_area) + (std_thresh * series_2_std))
|
|
133
|
+
)[0]
|
|
134
|
+
if len(series_2_pos_outlier_idx) > 0:
|
|
135
|
+
outlier_idx.extend(series_2_pos_outlier_idx)
|
|
136
|
+
# series 2 - negative outliers
|
|
137
|
+
series_2_neg_outlier_idx = np.where(
|
|
138
|
+
series_2_dtw_area
|
|
139
|
+
< (np.mean(series_2_dtw_area) - (std_thresh * series_2_std))
|
|
140
|
+
)[0]
|
|
141
|
+
if len(series_2_neg_outlier_idx) > 0:
|
|
142
|
+
outlier_idx.extend(series_1_neg_outlier_idx)
|
|
143
|
+
|
|
144
|
+
# remove duplicates
|
|
145
|
+
outlier_idx = np.unique(outlier_idx)
|
|
146
|
+
|
|
147
|
+
# remove outliers from BOTH series!
|
|
148
|
+
if len(outlier_idx) > 0:
|
|
149
|
+
series_1_dtw_area = np.delete(series_1_dtw_area, outlier_idx)
|
|
150
|
+
series_2_dtw_area = np.delete(series_2_dtw_area, outlier_idx)
|
|
151
|
+
|
|
152
|
+
# %% Save var, skew & kurtosis values of DTW Area Distributions
|
|
153
|
+
DTW_stats["series_1_var"] = np.var(series_1_dtw_area)
|
|
154
|
+
DTW_stats["series_1_skew"] = stats.skew(series_1_dtw_area)
|
|
155
|
+
DTW_stats["series_1_kurt"] = stats.kurtosis(series_1_dtw_area)
|
|
156
|
+
DTW_stats["series_2_var"] = np.var(series_2_dtw_area)
|
|
157
|
+
DTW_stats["series_2_skew"] = stats.skew(series_2_dtw_area)
|
|
158
|
+
DTW_stats["series_2_kurt"] = stats.kurtosis(series_2_dtw_area)
|
|
159
|
+
if outlier:
|
|
160
|
+
picklepath = os.path.join(
|
|
161
|
+
varpath, ("%iSD OutlierRejected Marginal Stats.pkl" % (std_thresh))
|
|
162
|
+
)
|
|
163
|
+
with open(picklepath, "wb") as file:
|
|
164
|
+
pickle.dump(DTW_stats, file)
|
|
165
|
+
else:
|
|
166
|
+
picklepath = os.path.join(varpath, ("Marginal Stats.pkl"))
|
|
167
|
+
with open(picklepath, "wb") as file:
|
|
168
|
+
pickle.dump(DTW_stats, file)
|
|
169
|
+
|
|
170
|
+
# %% Standardise DTW Area Distributions & correlate
|
|
171
|
+
# So line of linear fit in scatter & marginal distributions reflect correlation
|
|
172
|
+
# values better (since correlations have internal standardisation)
|
|
173
|
+
|
|
174
|
+
# zscore
|
|
175
|
+
z_x = stats.zscore(series_1_dtw_area)
|
|
176
|
+
z_y = stats.zscore(series_2_dtw_area)
|
|
177
|
+
|
|
178
|
+
# pearson corr
|
|
179
|
+
pears_res = stats.pearsonr(z_x, z_y)
|
|
180
|
+
pears_r = pears_res[0]
|
|
181
|
+
pears_p = pears_res[1]
|
|
182
|
+
|
|
183
|
+
# spearman corr
|
|
184
|
+
spear_res = stats.spearmanr(z_x, z_y)
|
|
185
|
+
spear_r = spear_res[0]
|
|
186
|
+
spear_p = spear_res[1]
|
|
187
|
+
|
|
188
|
+
# prepare strings
|
|
189
|
+
str_df = str(num_boots - 2) # degrees of freedom
|
|
190
|
+
pears_str_p = ""
|
|
191
|
+
spear_str_p = ""
|
|
192
|
+
if pears_p < 0.0001:
|
|
193
|
+
pears_str_p = "p < .0001"
|
|
194
|
+
else:
|
|
195
|
+
pears_str_p = "p = " + str(pears_p.round(3))
|
|
196
|
+
if spear_p < 0.0001:
|
|
197
|
+
spear_str_p = "p < .0001"
|
|
198
|
+
else:
|
|
199
|
+
spear_str_p = "p = " + str(spear_p.round(3))
|
|
200
|
+
|
|
201
|
+
# %% Plot one large figure
|
|
202
|
+
f, ax_global = plt.subplots()
|
|
203
|
+
ax_global.set_xticks([])
|
|
204
|
+
ax_global.set_yticks([])
|
|
205
|
+
for spine in ax_global.spines.values(): # despine outmost axis
|
|
206
|
+
spine.set_visible(False)
|
|
207
|
+
gs = f.add_gridspec(4, 4, wspace=0, hspace=0) # prepare the grid
|
|
208
|
+
scatter_color = "#c79fef" # lavender
|
|
209
|
+
line_color = "#8ab8fe" # carolina blue
|
|
210
|
+
xlimits = [-8, 8]
|
|
211
|
+
xticks = np.squeeze([-8, -4, 0, 4, 8])
|
|
212
|
+
ylimits = [-8, 8]
|
|
213
|
+
yticks = xticks.copy()
|
|
214
|
+
if try_to_fix_ylims:
|
|
215
|
+
ylimmax = num_boots / 8
|
|
216
|
+
ref_val_1 = round(ylimmax * 0.25)
|
|
217
|
+
ref_val_2 = round(ylimmax * 0.5)
|
|
218
|
+
ref_val_3 = round(ylimmax * 0.75)
|
|
219
|
+
|
|
220
|
+
# %% first, scatterplot
|
|
221
|
+
ax = f.add_subplot(gs[0:3, 1:]) # set wanted gridcells as ax
|
|
222
|
+
ax.scatter(
|
|
223
|
+
z_x, z_y, c="white", edgecolor=scatter_color, s=7, linewidth=0.5, alpha=0.6
|
|
224
|
+
)
|
|
225
|
+
ax.set_ylim(ylimits)
|
|
226
|
+
ax.set_xlim(xlimits)
|
|
227
|
+
ax.set_xticks(xticks)
|
|
228
|
+
ax.set_yticks(yticks)
|
|
229
|
+
ax.set_xticklabels([])
|
|
230
|
+
ax.set_yticklabels([])
|
|
231
|
+
series_1_xlimits = ax.get_xlim() # for plotting marginals later
|
|
232
|
+
series_1_xticks = ax.get_xticks()
|
|
233
|
+
series_2_xlimits = ax.get_ylim()
|
|
234
|
+
series_2_xticks = ax.get_yticks()
|
|
235
|
+
for spine in ax.spines.values():
|
|
236
|
+
spine.set_visible(False)
|
|
237
|
+
# ax.set_title("DTW area-diff correlations of %s & %s.\nPearson: r(%s) = %s: %s & Spearman: r(%s) = %s: %s" % (name_1, name_2, str_df, pears_str_r, pears_str_p, str_df, spear_str_r, spear_str_p))
|
|
238
|
+
ax.set_title(
|
|
239
|
+
"DTW area-diff correlations of "
|
|
240
|
+
+ name_1
|
|
241
|
+
+ " & "
|
|
242
|
+
+ name_2
|
|
243
|
+
+ "\nPearson: r("
|
|
244
|
+
+ str(str_df)
|
|
245
|
+
+ ") = "
|
|
246
|
+
+ str(pears_r.round(2))
|
|
247
|
+
+ ": "
|
|
248
|
+
+ pears_str_p
|
|
249
|
+
+ "\nSpearman: r("
|
|
250
|
+
+ str(str_df)
|
|
251
|
+
+ ") = "
|
|
252
|
+
+ str(spear_r.round(2))
|
|
253
|
+
+ ": "
|
|
254
|
+
+ spear_str_p
|
|
255
|
+
)
|
|
256
|
+
# have to create the line of least squares manually
|
|
257
|
+
coefficients = np.polyfit(z_x, z_y, 1) # 1 == linear (fit coefficients to orig z_x)
|
|
258
|
+
slope, intercept = coefficients
|
|
259
|
+
# create y_fit using new z_x that spans over the full xlimits (so line isnt cut-off)
|
|
260
|
+
z_x_new = np.arange(xlimits[0], xlimits[1] + 1, 1)
|
|
261
|
+
y_fit = intercept + (slope * z_x_new)
|
|
262
|
+
ax.plot(z_x_new, y_fit, linewidth=1.5, color=line_color)
|
|
263
|
+
|
|
264
|
+
# %% second, series_2 marginal distribution
|
|
265
|
+
ax_series_2 = f.add_subplot(gs[:-1, 0])
|
|
266
|
+
ax_series_2.hist(
|
|
267
|
+
z_y,
|
|
268
|
+
100,
|
|
269
|
+
orientation="horizontal",
|
|
270
|
+
facecolor=scatter_color,
|
|
271
|
+
edgecolor=line_color,
|
|
272
|
+
linewidth=0.15,
|
|
273
|
+
)
|
|
274
|
+
ax_series_2.set_ylim(series_2_xlimits) # set ylim because horizontal hist!
|
|
275
|
+
ax_series_2.set_yticks(series_2_xticks)
|
|
276
|
+
ax_series_2.invert_xaxis()
|
|
277
|
+
ax_series_2.set_ylabel(name_2 + " Marginal Distribution")
|
|
278
|
+
for side in ax_series_2.spines.keys(): # side is left/right/top/bottom
|
|
279
|
+
ax_series_2.spines[side].set_linewidth(0.2)
|
|
280
|
+
if side == "top":
|
|
281
|
+
ax_series_2.spines[side].set_visible(False)
|
|
282
|
+
if try_to_fix_ylims:
|
|
283
|
+
ax_series_2.set_xlim(left=ylimmax)
|
|
284
|
+
ref_val_strings = []
|
|
285
|
+
for val in [ref_val_1, ref_val_2, ref_val_3]:
|
|
286
|
+
ax_series_2.axvline(val, color="#029386", linewidth=0.2, linestyle="--")
|
|
287
|
+
ref_val_strings.append(str(val))
|
|
288
|
+
ax_series_2.set_xticks(
|
|
289
|
+
[ref_val_1, ref_val_2, ref_val_3], labels=ref_val_strings
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# %% third, series_1 marginal distribution
|
|
293
|
+
ax_series_1 = f.add_subplot(gs[-1, 1:])
|
|
294
|
+
ax_series_1.hist(
|
|
295
|
+
z_x, 100, facecolor=scatter_color, edgecolor=line_color, linewidth=0.15
|
|
296
|
+
)
|
|
297
|
+
ax_series_1.set_xlim(series_1_xlimits)
|
|
298
|
+
ax_series_1.set_xticks(series_1_xticks)
|
|
299
|
+
ax_series_1.set_xlabel(name_1 + " Marginal Distribution")
|
|
300
|
+
for side in ax_series_1.spines.keys(): # side is left/right/top/bottom
|
|
301
|
+
ax_series_1.spines[side].set_linewidth(0.2)
|
|
302
|
+
if side == "left":
|
|
303
|
+
ax_series_1.spines[side].set_visible(False)
|
|
304
|
+
if try_to_fix_ylims:
|
|
305
|
+
ax_series_1.set_ylim(top=ylimmax)
|
|
306
|
+
ref_val_strings = []
|
|
307
|
+
for val in [ref_val_1, ref_val_2, ref_val_3]:
|
|
308
|
+
ax_series_1.axhline(val, color="#029386", linewidth=0.2, linestyle="--")
|
|
309
|
+
ref_val_strings.append(str(val))
|
|
310
|
+
ax_series_1.set_yticks(
|
|
311
|
+
[ref_val_1, ref_val_2, ref_val_3], labels=ref_val_strings
|
|
312
|
+
)
|
|
313
|
+
ax_series_1.yaxis.set_ticks_position("right")
|
|
314
|
+
|
|
315
|
+
# %% save figures & marginal distributions
|
|
316
|
+
# figures
|
|
317
|
+
if outlier:
|
|
318
|
+
fig_filename = (
|
|
319
|
+
"DTW Latency Correlation - "
|
|
320
|
+
+ name_1
|
|
321
|
+
+ " & "
|
|
322
|
+
+ name_2
|
|
323
|
+
+ " - "
|
|
324
|
+
+ str(std_thresh)
|
|
325
|
+
+ "std outlier removed"
|
|
326
|
+
)
|
|
327
|
+
marginals_filename = (
|
|
328
|
+
"Marginal Distributions - " + str(std_thresh) + "std outlier removed"
|
|
329
|
+
)
|
|
330
|
+
else:
|
|
331
|
+
fig_filename = "DTW Latency Correlation - " + name_1 + " & " + name_2
|
|
332
|
+
marginals_filename = "Marginal Distributions"
|
|
333
|
+
f.savefig(
|
|
334
|
+
os.path.join(plotpath, fig_filename + ".png"),
|
|
335
|
+
bbox_inches="tight",
|
|
336
|
+
dpi=300,
|
|
337
|
+
)
|
|
338
|
+
f.savefig(
|
|
339
|
+
os.path.join(plotpath, fig_filename + ".svg"),
|
|
340
|
+
bbox_inches="tight",
|
|
341
|
+
dpi=300,
|
|
342
|
+
)
|
|
343
|
+
# marginal distributions
|
|
344
|
+
series_2_marginals = series_2_dtw_area
|
|
345
|
+
series_1_marginals = series_1_dtw_area
|
|
346
|
+
z_series_2_marginals = z_y
|
|
347
|
+
z_series_1_marginals = z_x
|
|
348
|
+
vars_to_save = {
|
|
349
|
+
"series_1_marginals": series_1_marginals,
|
|
350
|
+
"series_2_marginals": series_2_marginals,
|
|
351
|
+
"z_series_1_marginals": z_series_1_marginals,
|
|
352
|
+
"z_series_2_marginals": z_series_2_marginals,
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
picklepath = os.path.join(varpath, (marginals_filename + ".pkl"))
|
|
356
|
+
with open(picklepath, "wb") as file:
|
|
357
|
+
pickle.dump(vars_to_save, file)
|
|
358
|
+
plt.show(block=False) # otherwise stopped further code if python via terminal
|
|
359
|
+
plt.pause(0.001)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# %% Local functions
|
|
363
|
+
def run_bootstrap_dtw(series_1, series_2, num_boots):
|
|
364
|
+
"""Run the main bootstrap DTW analysis"""
|
|
365
|
+
# compute standardised (subjects / reps) averages of both time series
|
|
366
|
+
avg_series_1 = series_1.mean(axis=1)
|
|
367
|
+
avg_series_2 = series_2.mean(axis=1)
|
|
368
|
+
z_avg_series_1 = stats.zscore(avg_series_1)
|
|
369
|
+
z_avg_series_2 = stats.zscore(avg_series_2)
|
|
370
|
+
|
|
371
|
+
# bootstrap manually using randint & run dtw between bootstrap & observed z_avg
|
|
372
|
+
num_subs = np.shape(series_1)[1]
|
|
373
|
+
series_1_dtw_area = np.zeros(num_boots)
|
|
374
|
+
series_2_dtw_area = np.zeros(num_boots)
|
|
375
|
+
|
|
376
|
+
# bootstrap loop
|
|
377
|
+
for i in range(num_boots):
|
|
378
|
+
print("\nboots num #%s" % (i))
|
|
379
|
+
|
|
380
|
+
# get num_subj bootstrap samples (using randint == we bootstrap with duplicates)
|
|
381
|
+
this_trap = np.random.randint(0, num_subs, num_subs)
|
|
382
|
+
boot_series_1 = series_1[:, this_trap]
|
|
383
|
+
boot_series_2 = series_2[:, this_trap]
|
|
384
|
+
|
|
385
|
+
# average & standardise trapped series
|
|
386
|
+
avg_boot_series_1 = boot_series_1.mean(axis=1)
|
|
387
|
+
avg_boot_series_2 = boot_series_2.mean(axis=1)
|
|
388
|
+
z_avg_boot_series_1 = stats.zscore(avg_boot_series_1)
|
|
389
|
+
z_avg_boot_series_2 = stats.zscore(avg_boot_series_2)
|
|
390
|
+
|
|
391
|
+
# ***** dtw between z-scored averages of series & bootstrapped series *****
|
|
392
|
+
|
|
393
|
+
# series_1
|
|
394
|
+
WP_1, score_1 = dtw_path(z_avg_boot_series_1, z_avg_series_1)
|
|
395
|
+
ix_1 = []
|
|
396
|
+
iy_1 = []
|
|
397
|
+
for WP_idxs in WP_1:
|
|
398
|
+
ix_1.append(WP_idxs[0])
|
|
399
|
+
iy_1.append(WP_idxs[1])
|
|
400
|
+
xmax_1 = ix_1[-1] + 1 # because this is used with range
|
|
401
|
+
DIAG_1 = np.arange(xmax_1)
|
|
402
|
+
areaDIAG_1 = np.trapz(DIAG_1)
|
|
403
|
+
# IMPORTANT NOTE ABOUT NP.TRAPZ - TAKES IN (Y) & (Y,X) WHEREAS MATLAB'S TRAPZ
|
|
404
|
+
# TAKES (Y) & (X,Y) !!!!!
|
|
405
|
+
areaWP_1 = np.trapz(iy_1, ix_1)
|
|
406
|
+
series_1_dtw_area[i] = (areaDIAG_1 - areaWP_1) / areaDIAG_1
|
|
407
|
+
|
|
408
|
+
# series_2
|
|
409
|
+
WP_2, score_2 = dtw_path(z_avg_boot_series_2, z_avg_series_2)
|
|
410
|
+
ix_2 = []
|
|
411
|
+
iy_2 = []
|
|
412
|
+
for WP_idxs in WP_2:
|
|
413
|
+
ix_2.append(WP_idxs[0])
|
|
414
|
+
iy_2.append(WP_idxs[1])
|
|
415
|
+
xmax_2 = ix_2[-1] + 1 # because this is used with range
|
|
416
|
+
DIAG_2 = np.arange(xmax_2)
|
|
417
|
+
areaDIAG_2 = np.trapz(DIAG_2)
|
|
418
|
+
# IMPORTANT NOTE ABOUT NP.TRAPZ - TAKES IN (Y) & (Y,X) WHEREAS MATLAB'S TRAPZ
|
|
419
|
+
# TAKES (Y) & (X,Y) !!!!!
|
|
420
|
+
areaWP_2 = np.trapz(iy_2, ix_2)
|
|
421
|
+
series_2_dtw_area[i] = (areaDIAG_2 - areaWP_2) / areaDIAG_2
|
|
422
|
+
|
|
423
|
+
# bootstrap loop end
|
|
424
|
+
return series_1_dtw_area, series_2_dtw_area
|