From ade10d7c06a4a56d678638d7f9be28aa72ddadf1 Mon Sep 17 00:00:00 2001 From: jmoo2880 Date: Fri, 29 Aug 2025 13:00:38 +1000 Subject: [PATCH 1/3] add IDS SPI + config YAML --- pyspi/config.yaml | 12 ++++ pyspi/fast_config.yaml | 13 +++++ pyspi/lib/ids/__init__.py | 0 pyspi/lib/ids/dependence.py | 22 ++++++++ pyspi/lib/ids/numpy_dependence.py | 94 +++++++++++++++++++++++++++++++ pyspi/statistics/misc.py | 28 ++++++++- 6 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 pyspi/lib/ids/__init__.py create mode 100644 pyspi/lib/ids/dependence.py create mode 100644 pyspi/lib/ids/numpy_dependence.py diff --git a/pyspi/config.yaml b/pyspi/config.yaml index 5bb58553..fd215af4 100644 --- a/pyspi/config.yaml +++ b/pyspi/config.yaml @@ -1391,3 +1391,15 @@ - orth: True log: True absolute: True + + # Interdependence score + InterDependenceScore: + labels: + - unsigned + - undirected + - nonlinear + dependencies: + configs: # default params + - terms: 6 + pnorm: 'max' + bandwidth: 0.5 diff --git a/pyspi/fast_config.yaml b/pyspi/fast_config.yaml index e05f1cf2..9fbe31ee 100644 --- a/pyspi/fast_config.yaml +++ b/pyspi/fast_config.yaml @@ -1291,3 +1291,16 @@ - orth: True log: True absolute: True + + # Interdependence score + InterDependenceScore: + labels: + - unsigned + - undirected + - nonlinear + dependencies: + configs: # default params + - terms: 6 + pnorm: 'max' + bandwidth: 0.5 + \ No newline at end of file diff --git a/pyspi/lib/ids/__init__.py b/pyspi/lib/ids/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pyspi/lib/ids/dependence.py b/pyspi/lib/ids/dependence.py new file mode 100644 index 00000000..92637dd4 --- /dev/null +++ b/pyspi/lib/ids/dependence.py @@ -0,0 +1,22 @@ +from .numpy_dependence import compute_IDS_numpy + +def compute_IDS(X, Y=None, num_terms=6, p_norm='max', + p_val=False, num_tests=100, bandwidth_term=1/2): + """Compute IDS between all pairs of variables in X (or between X and Y). + + Taken from the implementation in: https://github.com/aradha/interdependence_scores + + + Parameters: + X: np.ndarray or torch.Tensor + Y: np.ndarray or torch.Tensor (optional) + num_terms: Number of terms for Taylor series approximation (optional) + p_norm: String 'max' if using IDS-max. 1 or 2 for IDS-1, IDS-2, respectively. (optional) + p_val: Boolean. Indicates whether to compute p-values using permutation tests + num_tests: Number of permutation tests if p_val=True + bandwidth_term: Constant term in Gaussian kernel + Returns: + IDS matrix, p-value matrix (if p_val=True) + """ + return compute_IDS_numpy(X, Y=Y, num_terms=num_terms, p_norm=p_norm, + p_val=p_val, num_tests=num_tests, bandwidth_term=bandwidth_term) \ No newline at end of file diff --git a/pyspi/lib/ids/numpy_dependence.py b/pyspi/lib/ids/numpy_dependence.py new file mode 100644 index 00000000..7a0f76d8 --- /dev/null +++ b/pyspi/lib/ids/numpy_dependence.py @@ -0,0 +1,94 @@ +import numpy as np +import math +import sys +from tqdm import tqdm + +SEED = 1717 +np.random.seed(SEED) + +EPSILON = sys.float_info.epsilon + +def transform(y, num_terms=6, bandwidth_term=1/2): + B = bandwidth_term + exp = np.exp(-B * y**2) + terms = [] + for i in range(num_terms): + terms.append(exp * (y)**i / math.sqrt(math.factorial(i) *1.)) + y_ = np.concatenate(terms, axis=-1) + return y_ + +def center(X): + return X - np.mean(X, axis=0, keepdims=True) + + +def compute_p_val(C, X, Y=None, num_terms=6, p_norm='max', n_tests=100, bandwidth_term=1/2): + + gt = C + count = 0 + + n, dx = X.shape + for i in tqdm(range(n_tests)): + + # Used to shuffle data + random_noise = np.random.normal(size=(n, dx)) + permutations = np.argsort(random_noise, axis=0) + X_permuted = X[permutations, np.arange(dx)[None, :]] + + if Y is not None: + n, dy = Y.shape + random_noise = np.random.normal(size=(n, dy)) + permutations = np.argsort(random_noise, axis=0) + Y_permuted = Y[permutations, np.arange(dy)[None, :]] + null = compute_IDS_numpy(X_permuted, Y=Y_permuted, num_terms=num_terms, + p_norm=p_norm, bandwidth_term=bandwidth_term) + else: + null = compute_IDS_numpy(X_permuted, Y=Y, num_terms=num_terms, + p_norm=p_norm, bandwidth_term=bandwidth_term) + + + count += np.where(null > gt, 1, 0) + + p_vals = count / n_tests + return p_vals + + +def compute_IDS_numpy(X, Y=None, num_terms=6, p_norm='max', + p_val=False, num_tests=100, bandwidth_term=1/2): + n, dx = X.shape + X_t = transform(X, num_terms=num_terms, bandwidth_term=bandwidth_term) + X_t = center(X_t) + + if Y is not None: + _, dy = Y.shape + Y_t = transform(Y, num_terms=num_terms, bandwidth_term=bandwidth_term) + Y_t = center(Y_t) + cov = X_t.T @ Y_t + X_std = np.sqrt(np.sum(X_t**2, axis=0)) + Y_std = np.sqrt(np.sum(Y_t**2, axis=0)) + correlations = cov / (X_std.reshape(-1, 1) + EPSILON) + C = correlations / (Y_std.reshape(1, -1) + EPSILON) + C = C.reshape(num_terms, dx, num_terms, dy) + else: + C = np.corrcoef(X_t.T) + C = C.reshape(num_terms, dx, num_terms, dx) + + C = np.nan_to_num(C, nan=0, posinf=0, neginf=0) + C = np.abs(C) + + if p_norm == 'max': + C = np.amax(C, axis=(0, 2)) + elif p_norm == 2: + C = C**2 + C = np.mean(C, axis=0) + C = np.mean(C, axis=1) + C = np.sqrt(C) + elif p_norm == 1: + C = np.mean(C, axis=0) + C = np.mean(C, axis=1) + + if p_val: + p_vals = compute_p_val(C, X, Y=Y, num_terms=num_terms, p_norm=p_norm, + n_tests=num_tests, bandwidth_term=bandwidth_term) + return C, p_vals + else: + return C diff --git a/pyspi/statistics/misc.py b/pyspi/statistics/misc.py index a99a48d6..b885b49a 100644 --- a/pyspi/statistics/misc.py +++ b/pyspi/statistics/misc.py @@ -8,6 +8,7 @@ from sklearn.metrics import mean_squared_error from sklearn import linear_model import mne.connectivity as mnec +from pyspi.lib.ids.dependence import compute_IDS from pyspi.base import ( Directed, @@ -147,7 +148,7 @@ def bivariate(self, data, i=None, j=None): class PowerEnvelopeCorrelation(Undirected, Unsigned): - humanname = "Power envelope correlation" + name = "Power envelope correlation" identifier = "pec" labels = ["unsigned", "misc", "undirected"] @@ -173,3 +174,28 @@ def multivariate(self, data): ) np.fill_diagonal(adj, np.nan) return adj + +class InterDependenceScore(Undirected, Unsigned): + name = "Interdependence score" + identifier = "ids" + labels = ["unsigned", "misc", "undirected", "nonlinear"] + + def __init__( + self, + terms=6, + pnorm='max', + bandwidth=0.5 + ): + self._num_terms = terms + self._p_norm = pnorm + self._bandwidth_term = bandwidth + + + @parse_multivariate + def multivariate(self, data): + # reshape for the compute_IDS function which expects shape (obs, proc) + z = np.squeeze(data.to_numpy(), axis=2).T + ids = compute_IDS(z, num_terms=self._num_terms, p_norm=self._p_norm, + bandwidth_term=self._bandwidth_term) + return ids + \ No newline at end of file From 6b8349548a47ff8750e4baefd86fedd837c36218 Mon Sep 17 00:00:00 2001 From: jmoo2880 Date: Fri, 29 Aug 2025 13:18:01 +1000 Subject: [PATCH 2/3] add software license for IDS + author credit --- pyspi/lib/ids/LICENSE.txt | 21 +++++++++++++++++++++ pyspi/lib/ids/dependence.py | 13 +++++++++++-- pyspi/lib/ids/numpy_dependence.py | 5 +++++ 3 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 pyspi/lib/ids/LICENSE.txt diff --git a/pyspi/lib/ids/LICENSE.txt b/pyspi/lib/ids/LICENSE.txt new file mode 100644 index 00000000..7f1e5c22 --- /dev/null +++ b/pyspi/lib/ids/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Adityanarayanan Radhakrishnan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/pyspi/lib/ids/dependence.py b/pyspi/lib/ids/dependence.py index 92637dd4..7402132c 100644 --- a/pyspi/lib/ids/dependence.py +++ b/pyspi/lib/ids/dependence.py @@ -1,11 +1,20 @@ +""" +Interdependence Score (IDS) computation. +Based on the work by Adityanarayanan Radhakrishnan (MIT License) +Original: https://github.com/aradha/interdependence_scores +Modified for use in pyspi package. +""" from .numpy_dependence import compute_IDS_numpy def compute_IDS(X, Y=None, num_terms=6, p_norm='max', p_val=False, num_tests=100, bandwidth_term=1/2): """Compute IDS between all pairs of variables in X (or between X and Y). - Taken from the implementation in: https://github.com/aradha/interdependence_scores + This is a modified version of the implementation from: + https://github.com/aradha/interdependence_scores + Original author: Adityanarayanan Radhakrishnan + License: MIT (see LICENSE.txt) Parameters: X: np.ndarray or torch.Tensor @@ -19,4 +28,4 @@ def compute_IDS(X, Y=None, num_terms=6, p_norm='max', IDS matrix, p-value matrix (if p_val=True) """ return compute_IDS_numpy(X, Y=Y, num_terms=num_terms, p_norm=p_norm, - p_val=p_val, num_tests=num_tests, bandwidth_term=bandwidth_term) \ No newline at end of file + p_val=p_val, num_tests=num_tests, bandwidth_term=bandwidth_term) diff --git a/pyspi/lib/ids/numpy_dependence.py b/pyspi/lib/ids/numpy_dependence.py index 7a0f76d8..09f3e5d7 100644 --- a/pyspi/lib/ids/numpy_dependence.py +++ b/pyspi/lib/ids/numpy_dependence.py @@ -1,3 +1,8 @@ +""" +Interdependence Score (IDS) computation. +Based on the work by Adityanarayanan Radhakrishnan (MIT License) +Original: https://github.com/aradha/interdependence_scores +""" import numpy as np import math import sys From 1e4f17b0207db7467aabd6c766e2817982f1a157 Mon Sep 17 00:00:00 2001 From: jmoo2880 Date: Fri, 29 Aug 2025 19:40:15 +1000 Subject: [PATCH 3/3] update benchmarking tables --- tests/CML7_benchmark_tables.pkl | Bin 256931 -> 257809 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/CML7_benchmark_tables.pkl b/tests/CML7_benchmark_tables.pkl index f23b1df259aeb4de89ddc8b28392f0f0e7e2715b..23d5a4753849e7e10b354c0ee1179205c89fa2d2 100644 GIT binary patch delta 10486 zcmYM42{@E(8-^{FtuiAdDk}LDii8OFTgYFQloARpw$d(@C1Z(5)+v*;kV;e%EmRD$ zZ%Krb>^rGcD)i4c-uwMJ4o4k2@9RFV=Na)*QiEu8i)i}3X$*y3qo)mcJ7KKGdsKel z6VzmP-?Uos0WQg3T9@L~0PD5QWejGu15b#aW{t#WK%R&DYm5=$bZP$UW&HqimJBIy zIIj;Yiq3m>zyZa1YNyye;NWMu56=IWk37yZZnO6ncH0cX3d2cd`_Mk{w)9<4aG(d| zHg2#K3~8l=Z~^id@MM+29^JMfm_gU{p7psKek|>?EO2e13ZM|?BR@6I7-I_(4>W#L!0~8lLxfIjY|ag|7uWnoE^@pgU(1e}#AeAddzw1P=X?%xA%~VzCAH zUS0&5ss}@L9&VuFEs#7b+Y-#C-In~o6G-JFkMj)CM4uVTS|{O9yn$;1ID1`aQPt9}gfJdoF zN21Xmf|UNZ&Es}B)fgL~5H3I-1zZ|vojL}(uElZ zFU;-l&Flc*d3(h*&a^^^TwP>!%STwzAmMSpy$NUw5^4uT+hL;f{`t7FVL%=|R^)44 ztZVFtvhd9@gj)xA_mpW~Y3>B!#lEkMGT=!hxY~KsbD;FyDs=%EjrT(ZvK zBRpd2Dh#+%5@b;n;0V2zd;XrQb^t}1-j(?PK$^E`+7?4MW z4N701M7nmt=`|KUwvKh6cK6S?`{M@qTD$buvm0$NU}GSg;qacyM;_-HS^F!cBNq07 zzw^rIC3`#Je5B)gk)>@g{n97x9EmrS5H3I-0}gd{uKL_N03#6}_>UZG1$RB4A4|Se zQUy>5^O2vL=UjsGW8KWLZGF%+7MCbjSV}d<1}KCJkVk<9pX9w)=e(y*2peMo6vBMu zx%1o)gfT7KkY`a0u*_I3DQ@WpJz65BR?QI*ed@bnrVaNaxD~ZA1Hqn4Q z8eC(1apqIJ1s;P>3V#P)2Hr`nV`=qAf#;U&-dYViP@5TR+c|iS%10jO83S4);gLas zFq$;&d6pd$5(`!OkFAM=$$DcVa9IE)gbR?zfTW9Ro6jeH+$(=NZEF8`4!Hm z1uTFu5H`jFD1`aQ zbLTTpEh!gfEWEvb$r8gxkZv7ZXFXX1F(YO+)7vYcV2(`Hn~_2YUL|hR>Qo0EX5!yY zBn$!asPJf>j_8@-Zg^tQaW6W$0nFNej%RCq06ydXD{5QXp(J6hNKSYYm5)5mGa_D` z8&;0$gX85ITNc=MftStGrjHTrV1431@&4nrln^dJ9s?$hsQfC-?1#0r*{Z`6ZIC*4 zG%97{4OIYzFdzA;dCn!6+&{K0GNT{5=}Pex`PEcoY=AUp#xb@ofl5 z9#}K*RqhHPj|xgxE!WRC4uQ)1YgVsKv;&dDrD=A&m*B%P#|P#{Q6PI`Xq$)Dc`6@y zoM)VP_WsmP{qvw4Hf}34Hxb;u&t`?s4}|0++lw40-6sGukfY{pb+LGKQ+&}1U2jB0no?s`_2JR{|~jKBo&ZJH9o1;}H7 zRa%M4oCtc}VLjzKbC))D+l_tQdn3M31uRG*%twA|o^uIS7Bcq>T_y7L#w%sF+^&IL zWMgcALI=qO+ejV-o_eeaqsQ^&Ns%WMKsGib3!uY1xx^~BYseKi2=p^PD@dxmvZq`%)FiIj$EdN_hrf$i{AD12s}elw5#33VeNPe6c*{8l;dX)Ic`2 zBnzOBBAJgocRup~6bLh3SR9MLVLB}@(AbGSaK9W5EM@JVGq0O{Dmr(8{`K#0lEx|$ z8XtxgOT87oELX^TN%E+WEZ-qwdU;je`E7}h*RGN$o+&xhns6QXh;Sx_LdgZl zV*t;~Wr{b$X65y*bDJmM(#vMKguOap#gm5xvPdD!M}BIaa|zGoex#UglFajsd&;(s zdj@C7#@GObK9dWwNgf5Fj&z(7ZTih#NuCfk#sVmW`N(tUnaRI27B}CMJ{>X2Ig-zbZgO?U=x{NPVzXIUf^Jl9{}EUUE`^mN{+=zs8^-p`F?|aM$O3Fqh)w24 zlKj*>=MI9?h7;qCrGxo|=_2~!S0F?-#s(;KfLsty@+h#?Csen}yBOY*Cxnf$019C~ z^4$5%on}?Sj6Al^K*XAr#JiHSv8n9>#OZUj7Amd6M9kh>A>r=4#M|1x-}UT&L)i`U zz}HFph*uXsH9*}H@C#gdR^fXn8%!hL$#9f35FH#5>{3Lk{*ffJ@^0
  • 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