mvpure-py 2.3.2__py3-none-any.whl
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.
- mvpure_py/__init__.py +3 -0
- mvpure_py/_version.py +24 -0
- mvpure_py/localizer/__init__.py +21 -0
- mvpure_py/localizer/_utils.py +178 -0
- mvpure_py/localizer/localizer_utils.py +345 -0
- mvpure_py/localizer/mvpure_localizer.py +783 -0
- mvpure_py/simulation/__init__.py +16 -0
- mvpure_py/simulation/simulate.py +722 -0
- mvpure_py/simulation/validate.py +487 -0
- mvpure_py/tests/__init__.py +0 -0
- mvpure_py/tests/test_algebra.py +65 -0
- mvpure_py/tests/test_forward_subset.py +229 -0
- mvpure_py/tests/test_helper.py +82 -0
- mvpure_py/tests/test_translation.py +155 -0
- mvpure_py/tests/test_utils.py +53 -0
- mvpure_py/utils/__init__.py +18 -0
- mvpure_py/utils/_helper.py +36 -0
- mvpure_py/utils/_utils.py +68 -0
- mvpure_py/utils/algebra.py +14 -0
- mvpure_py/utils/translation.py +503 -0
- mvpure_py/viz/__init__.py +19 -0
- mvpure_py/viz/group_viz.py +201 -0
- mvpure_py/viz/viz.py +219 -0
- mvpure_py-2.3.2.dist-info/METADATA +143 -0
- mvpure_py-2.3.2.dist-info/RECORD +28 -0
- mvpure_py-2.3.2.dist-info/WHEEL +5 -0
- mvpure_py-2.3.2.dist-info/licenses/LICENSE +28 -0
- mvpure_py-2.3.2.dist-info/top_level.txt +1 -0
mvpure_py/__init__.py
ADDED
mvpure_py/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '2.3.2'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 3, 2)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = 'g8b490f09b'
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
References
|
|
3
|
+
----------
|
|
4
|
+
.. [1] Jurkowska, J., Dreszer, J., Lewandowska, M., Tolpa, K., & Piotrowski, T. J. (2025).
|
|
5
|
+
Multi-Source Neural Activity Indices and Spatial Filters for EEG/MEG Inverse Problem: An Extension to MNE-Python. bioRxiv, 2025-09.
|
|
6
|
+
"""
|
|
7
|
+
from .mvpure_localizer import (
|
|
8
|
+
localize,
|
|
9
|
+
Localized,
|
|
10
|
+
read_localized
|
|
11
|
+
)
|
|
12
|
+
from .localizer_utils import (
|
|
13
|
+
get_activity_index,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from ._utils import (
|
|
17
|
+
_check_localize_params,
|
|
18
|
+
_prepare_localize_params,
|
|
19
|
+
_check_vertices_and_indices,
|
|
20
|
+
_check_n_sources_param
|
|
21
|
+
)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from ..utils import _check_hemi_param
|
|
2
|
+
|
|
3
|
+
import mne
|
|
4
|
+
import numpy as np
|
|
5
|
+
from termcolor import colored
|
|
6
|
+
import logging
|
|
7
|
+
logging.basicConfig(
|
|
8
|
+
level=logging.WARNING,
|
|
9
|
+
format='[%(levelname)s] %(message)s'
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _check_localize_params(
|
|
14
|
+
forward: mne.Forward,
|
|
15
|
+
leadfield: np.array) -> None:
|
|
16
|
+
"""
|
|
17
|
+
Checking user input for mvpure_py.localizer.localize() function.
|
|
18
|
+
"""
|
|
19
|
+
if forward is None and leadfield is None:
|
|
20
|
+
raise ValueError("One of the parameters 'forward' and 'leadfield' should not be None")
|
|
21
|
+
|
|
22
|
+
if forward is not None and leadfield is not None:
|
|
23
|
+
if np.array_equal(forward['sol']['data'], leadfield):
|
|
24
|
+
raise ValueError("Both parameters 'forward' and 'leadfield' were specified. "
|
|
25
|
+
"They should be equal, but they are not.")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _prepare_localize_params(
|
|
29
|
+
forward: mne.Forward,
|
|
30
|
+
leadfield: np.array) -> np.array:
|
|
31
|
+
"""
|
|
32
|
+
Adjusting input parameters in mvpure_py.localizer.localize() function.
|
|
33
|
+
Handling forward/leadfield options.
|
|
34
|
+
"""
|
|
35
|
+
if forward is not None:
|
|
36
|
+
leadfield = forward["sol"]["data"]
|
|
37
|
+
|
|
38
|
+
return leadfield
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _check_n_sources_param(n_sources: int) -> None:
|
|
42
|
+
"""
|
|
43
|
+
Checks type of 'n_sources_to_localize' from mvpure_py.localizer.localize() function.
|
|
44
|
+
Should be an integer.
|
|
45
|
+
"""
|
|
46
|
+
if not isinstance(n_sources, (int, np.integer)):
|
|
47
|
+
raise TypeError(f"Parameter 'n_sources_to_localize' should be an integer. Got type: {type(n_sources)} instead.")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _check_rank(
|
|
51
|
+
r: int | str | float | list,
|
|
52
|
+
n_sources_to_localize: int):
|
|
53
|
+
"""
|
|
54
|
+
Checking correctness of rank optimization ('r') parameter.
|
|
55
|
+
"""
|
|
56
|
+
if not isinstance(r, (int, np.integer, str, float, np.floating, list)):
|
|
57
|
+
raise ValueError(f"Optimization parameter 'r' (set to: {r}) should be of type: int, float, string or list. "
|
|
58
|
+
f"Got {type(r)} instead.")
|
|
59
|
+
|
|
60
|
+
# if 'r' is a list, it should contain integers / floats
|
|
61
|
+
# and all elements should be equal or less to n_sources_to_localize,
|
|
62
|
+
if isinstance(r, list):
|
|
63
|
+
if not all(
|
|
64
|
+
isinstance(x, (int, np.integer, float, np.floating)) and
|
|
65
|
+
n_sources_to_localize >= x >= 1
|
|
66
|
+
for x in r
|
|
67
|
+
):
|
|
68
|
+
raise ValueError("If `r` is a list, all elements should be int or float"
|
|
69
|
+
" and equal or less than number of sources to localize.")
|
|
70
|
+
|
|
71
|
+
# 'r' should be less or equal to number of sources to localize
|
|
72
|
+
if isinstance(r, (int, np.integer)) and r > n_sources_to_localize:
|
|
73
|
+
raise ValueError(f"Optimization parameter 'r' (set to {r}) should be equal or less than"
|
|
74
|
+
f" number of sources to localize ('n_sources_to_localize' set to {n_sources_to_localize}).")
|
|
75
|
+
|
|
76
|
+
# only option for 'r' as string is 'full'
|
|
77
|
+
if isinstance(r, str) and r not in ["full"]:
|
|
78
|
+
raise ValueError(f"If parameter 'r' is a string it should be set to 'full'. "
|
|
79
|
+
f"Got {r} instead.")
|
|
80
|
+
|
|
81
|
+
# if 'r' == 0, use full rank
|
|
82
|
+
if isinstance(r, (int, np.integer)) and r == 0:
|
|
83
|
+
return "full"
|
|
84
|
+
|
|
85
|
+
# if 'r' is smaller than zero, take n_sources_to_localize - abs(r)
|
|
86
|
+
if isinstance(r, (int, np.integer)) and r < 0 and (n_sources_to_localize - abs(r)) > 0:
|
|
87
|
+
adjusted_value = n_sources_to_localize - abs(r)
|
|
88
|
+
logging.warning(
|
|
89
|
+
f"Got rank lower than zero. Taking n_sources_to_localize - {abs(r)} = {adjusted_value} instead."
|
|
90
|
+
)
|
|
91
|
+
return adjusted_value
|
|
92
|
+
|
|
93
|
+
# if 'r' is smaller than zero and n_sources_to_localize - abs(r) == 0, use full rank
|
|
94
|
+
if isinstance(r, (int, np.integer)) and r < 0 and (n_sources_to_localize - abs(r)) == 0:
|
|
95
|
+
return "full"
|
|
96
|
+
|
|
97
|
+
# if 'r' is smaller than zero and n_sources_to_localize - abs(r) == 0, raise error
|
|
98
|
+
if isinstance(r, (int, np.integer)) and r < 0 and (n_sources_to_localize - abs(r)) < 0:
|
|
99
|
+
raise ValueError(f"Optimization parameter 'r' (set to {r}) should be equal or higher than 0.")
|
|
100
|
+
|
|
101
|
+
# if using float and float can not be converted to integer - raise error
|
|
102
|
+
if isinstance(r, (float, np.floating)) and int(r) != r:
|
|
103
|
+
raise TypeError(f"Parameter 'r' should be type an integer (if numeric). Got float instead.")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _prepare_localizer_to_use(localizer_to_use: str | list[str]):
|
|
107
|
+
"""
|
|
108
|
+
Checking and adjusting 'localizer_to_use' parameter from mvpure_py.localizer.localize() function.
|
|
109
|
+
"""
|
|
110
|
+
# list of all localizers options
|
|
111
|
+
all_localizers = ['mai', 'mai_mvp']
|
|
112
|
+
# if localizer_to_use is a string
|
|
113
|
+
if isinstance(localizer_to_use, str):
|
|
114
|
+
# if localizer_to_use == 'all' use all localizers
|
|
115
|
+
if localizer_to_use == 'all':
|
|
116
|
+
param_localizer_to_use = all_localizers
|
|
117
|
+
# can be single name of localizers
|
|
118
|
+
elif localizer_to_use in all_localizers:
|
|
119
|
+
param_localizer_to_use = [localizer_to_use]
|
|
120
|
+
# otherwise raise ValueError
|
|
121
|
+
else:
|
|
122
|
+
raise ValueError(f"If 'localizer_to_use' is a string it should be either 'all' or "
|
|
123
|
+
f"name of one of the localizers to use. Possible options are: {all_localizers}. "
|
|
124
|
+
f"Got '{localizer_to_use}' instead.")
|
|
125
|
+
elif isinstance(localizer_to_use, list):
|
|
126
|
+
param_localizer_to_use = [loc for loc in localizer_to_use if loc in all_localizers]
|
|
127
|
+
if len(param_localizer_to_use) < len(localizer_to_use):
|
|
128
|
+
print(colored(
|
|
129
|
+
f"WARNING: One or more of localizers' names are not valid."
|
|
130
|
+
f" Possible options are: {all_localizers}, got {localizer_to_use}.",
|
|
131
|
+
"yellow"
|
|
132
|
+
))
|
|
133
|
+
if len(param_localizer_to_use) == 0:
|
|
134
|
+
raise ValueError(f"None of the localizers' name are valid."
|
|
135
|
+
f" Possible options are: {all_localizers}, got {localizer_to_use}")
|
|
136
|
+
else:
|
|
137
|
+
raise ValueError(f"'localizer_to_use' should be either list of valid localizers' names "
|
|
138
|
+
f"or string with single localizer name")
|
|
139
|
+
return param_localizer_to_use
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _define_hemi_based_on_vertices(localized) -> str:
|
|
143
|
+
"""
|
|
144
|
+
Define hemi (`both`, `rh`, `lh`) based on vertices stored in `localized` object.
|
|
145
|
+
"""
|
|
146
|
+
if hasattr(localized['stc'], 'lh_vertno') and hasattr(localized['stc'], 'rh_vertno'):
|
|
147
|
+
hemi = "both"
|
|
148
|
+
elif hasattr(localized['stc'], 'lh_vertno'):
|
|
149
|
+
hemi = "lh"
|
|
150
|
+
elif hasattr(localized['stc'], 'rh_vertno'):
|
|
151
|
+
hemi = "rh"
|
|
152
|
+
|
|
153
|
+
return hemi
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _check_norm_param(norm):
|
|
157
|
+
if norm not in ["max", "sum"]:
|
|
158
|
+
raise ValueError(f"'norm' can be equal to one of the following: "
|
|
159
|
+
f" 'max' (max normalization), 'sum' (L1 normalization. "
|
|
160
|
+
f"Got {norm} instead.")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _check_vertices_and_indices(lh_vertices: list, lh_indices: list, rh_vertices: list, rh_indices: list):
|
|
164
|
+
if (lh_indices is not None and lh_vertices is None) or (lh_indices is None and lh_vertices is not None):
|
|
165
|
+
raise ValueError(f"Both 'lh_vertices' and 'lh_indices' should be specified. "
|
|
166
|
+
f"Got lh_vertices={lh_vertices} and lh_indices={lh_indices}.")
|
|
167
|
+
|
|
168
|
+
if (rh_indices is not None and rh_vertices is None) or (rh_indices is None and rh_vertices is not None):
|
|
169
|
+
raise ValueError(f"Both 'rh_vertices' and 'rh_indices' should be specified. "
|
|
170
|
+
f"Got rh_vertices={rh_vertices} and rh_indices={rh_indices}.")
|
|
171
|
+
|
|
172
|
+
if lh_vertices is not None and lh_indices is not None and len(lh_vertices) != len(lh_indices):
|
|
173
|
+
raise ValueError(f"Can not add vertices to Localized object because length of vertices list ({len(lh_vertices)})"
|
|
174
|
+
f"does not match length of indices list ({len(lh_indices)}).")
|
|
175
|
+
|
|
176
|
+
if rh_vertices is not None and rh_indices is not None and len(rh_vertices) != len(rh_indices):
|
|
177
|
+
raise ValueError(f"Can not add vertices to Localized object because length of vertices list ({len(rh_vertices)})"
|
|
178
|
+
f"does not match length of indices list ({len(rh_indices)}).")
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
""" Functions used during sources localization. """
|
|
2
|
+
|
|
3
|
+
# Authors: Julia Jurkowska, Tomasz Piotrowski
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import List, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from joblib import Parallel, delayed
|
|
10
|
+
from scipy.linalg import cho_factor, cho_solve, pinv
|
|
11
|
+
from tqdm import tqdm
|
|
12
|
+
|
|
13
|
+
# Number of threads used by BLAS/LAPACK - ADJUSTABLE
|
|
14
|
+
os.environ["OMP_NUM_THREADS"] = "10"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ============================================================
|
|
18
|
+
# -------- PSD low-rank pseudo-inverse (apply-only) ----------
|
|
19
|
+
# ============================================================
|
|
20
|
+
|
|
21
|
+
class PSDPinvApply:
|
|
22
|
+
"""
|
|
23
|
+
Robust pseudo-inverse operator for PSD (possibly low-rank) matrix A:
|
|
24
|
+
A^+ ≈ U diag(1/lam) U^T (truncated eigenpairs)
|
|
25
|
+
|
|
26
|
+
Use: op.apply(X) = A^+ @ X
|
|
27
|
+
"""
|
|
28
|
+
__slots__ = ("U", "lam", "cutoff")
|
|
29
|
+
|
|
30
|
+
def __init__(self, U: np.ndarray, lam: np.ndarray, cutoff: float):
|
|
31
|
+
self.U = U
|
|
32
|
+
self.lam = lam
|
|
33
|
+
self.cutoff = cutoff
|
|
34
|
+
|
|
35
|
+
def apply(self, X: np.ndarray) -> np.ndarray:
|
|
36
|
+
if self.U.size == 0:
|
|
37
|
+
return np.zeros_like(X)
|
|
38
|
+
UtX = self.U.T @ X
|
|
39
|
+
return self.U @ (UtX / self.lam[:, None])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def psd_pinv_operator(A: np.ndarray,
|
|
43
|
+
rmax: Optional[int] = None,
|
|
44
|
+
eps_rel: float = 1e-10) -> PSDPinvApply:
|
|
45
|
+
"""
|
|
46
|
+
Build a robust pseudo-inverse *operator* for PSD A (low-rank supported).
|
|
47
|
+
|
|
48
|
+
Parameters
|
|
49
|
+
----------
|
|
50
|
+
A : (m,m) PSD matrix (may be singular)
|
|
51
|
+
rmax : Optional[int]
|
|
52
|
+
Keep at most rmax eigenpairs (largest).
|
|
53
|
+
eps_rel : float
|
|
54
|
+
Keep eigenvalues > eps_rel * max_eig.
|
|
55
|
+
|
|
56
|
+
Returns
|
|
57
|
+
-------
|
|
58
|
+
PSDPinvApply
|
|
59
|
+
.apply(X) computes A^+ @ X
|
|
60
|
+
"""
|
|
61
|
+
As = 0.5 * (A + A.T) # enforce symmetry numerically
|
|
62
|
+
lam_all, U_all = np.linalg.eigh(As) # ascending
|
|
63
|
+
lam_max = lam_all[-1] if lam_all.size else 0.0
|
|
64
|
+
|
|
65
|
+
if lam_max <= 0:
|
|
66
|
+
return PSDPinvApply(
|
|
67
|
+
U=np.zeros((A.shape[0], 0), dtype=A.dtype),
|
|
68
|
+
lam=np.zeros((0,), dtype=A.dtype),
|
|
69
|
+
cutoff=0.0
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
cutoff = eps_rel * float(lam_max)
|
|
73
|
+
keep = lam_all > cutoff
|
|
74
|
+
|
|
75
|
+
if rmax is not None and int(np.sum(keep)) > int(rmax):
|
|
76
|
+
idx = np.argsort(lam_all)[-int(rmax):]
|
|
77
|
+
keep2 = np.zeros_like(keep, dtype=bool)
|
|
78
|
+
keep2[idx] = True
|
|
79
|
+
keep = keep & keep2
|
|
80
|
+
|
|
81
|
+
lam = lam_all[keep].astype(A.dtype, copy=False)
|
|
82
|
+
U = U_all[:, keep].astype(A.dtype, copy=False)
|
|
83
|
+
|
|
84
|
+
return PSDPinvApply(U=U, lam=lam, cutoff=cutoff)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ============================================================
|
|
88
|
+
# ---------------- Helper: build batch blocks ----------------
|
|
89
|
+
# ============================================================
|
|
90
|
+
|
|
91
|
+
def _build_blocks_batch(H_sel: np.ndarray,
|
|
92
|
+
M_sel: Optional[np.ndarray],
|
|
93
|
+
A_M_batch: np.ndarray,
|
|
94
|
+
H_batch: np.ndarray) -> np.ndarray:
|
|
95
|
+
"""
|
|
96
|
+
Construct block matrices for a batch of candidate sources.
|
|
97
|
+
"""
|
|
98
|
+
k = 0 if M_sel is None else M_sel.shape[0]
|
|
99
|
+
B = A_M_batch.shape[1]
|
|
100
|
+
m = k + 1
|
|
101
|
+
dtype = A_M_batch.dtype
|
|
102
|
+
|
|
103
|
+
if k == 0:
|
|
104
|
+
s_batch = np.sum(A_M_batch * H_batch, axis=0)
|
|
105
|
+
return s_batch.reshape(B, 1, 1).astype(dtype, copy=False)
|
|
106
|
+
|
|
107
|
+
a_batch = H_sel[:, :k].T @ A_M_batch
|
|
108
|
+
s_batch = np.sum(A_M_batch * H_batch, axis=0)
|
|
109
|
+
|
|
110
|
+
blocks = np.empty((B, m, m), dtype=dtype)
|
|
111
|
+
blocks[:, :k, :k] = M_sel[None, :, :]
|
|
112
|
+
blocks[:, :k, k:k+1] = a_batch.T.reshape(B, k, 1)
|
|
113
|
+
blocks[:, k:k+1, :k] = a_batch.T.reshape(B, 1, k)
|
|
114
|
+
blocks[:, k, k] = s_batch
|
|
115
|
+
|
|
116
|
+
return blocks
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ============================================================
|
|
120
|
+
# ---------------- Activity index: simplified ----------------
|
|
121
|
+
# ============================================================
|
|
122
|
+
|
|
123
|
+
def _simple_mai_batch(blocks_G: np.ndarray,
|
|
124
|
+
blocks_S: np.ndarray,
|
|
125
|
+
current_iter: int) -> np.ndarray:
|
|
126
|
+
"""
|
|
127
|
+
MAI = trace(G S⁻¹) - current_iter
|
|
128
|
+
"""
|
|
129
|
+
B, m, _ = blocks_S.shape
|
|
130
|
+
|
|
131
|
+
if m == 1:
|
|
132
|
+
return blocks_G[:, 0, 0] / blocks_S[:, 0, 0] - current_iter
|
|
133
|
+
|
|
134
|
+
I = np.eye(m)
|
|
135
|
+
|
|
136
|
+
def _process_block(i):
|
|
137
|
+
try:
|
|
138
|
+
invS = cho_solve(cho_factor(blocks_S[i], lower=False, check_finite=False),
|
|
139
|
+
I, check_finite=False)
|
|
140
|
+
except np.linalg.LinAlgError:
|
|
141
|
+
invS = pinv(blocks_S[i])
|
|
142
|
+
|
|
143
|
+
return np.trace(blocks_G[i] @ invS) - current_iter
|
|
144
|
+
|
|
145
|
+
results = Parallel(n_jobs=-1)(delayed(_process_block)(i) for i in range(B))
|
|
146
|
+
return np.array(results, dtype=float)
|
|
147
|
+
|
|
148
|
+
# ============================================================
|
|
149
|
+
# ---------------- Activity index: full versions --------------
|
|
150
|
+
# ============================================================
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _mai_mvp_batch(blocks_G: np.ndarray,
|
|
154
|
+
blocks_S: np.ndarray,
|
|
155
|
+
r: int,
|
|
156
|
+
current_iter: int) -> np.ndarray:
|
|
157
|
+
"""
|
|
158
|
+
MAI-MVP = sum of top-r eigenvalues of (G S⁻¹) - r
|
|
159
|
+
"""
|
|
160
|
+
B, m, _ = blocks_S.shape
|
|
161
|
+
|
|
162
|
+
if current_iter <= r:
|
|
163
|
+
return _simple_mai_batch(blocks_G, blocks_S, current_iter)
|
|
164
|
+
|
|
165
|
+
I = np.eye(m)
|
|
166
|
+
|
|
167
|
+
def _process_block(i):
|
|
168
|
+
if m == 1:
|
|
169
|
+
return blocks_G[i, 0, 0] / blocks_S[i, 0, 0] - r
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
invS = cho_solve(cho_factor(blocks_S[i], lower=False, check_finite=False),
|
|
173
|
+
I, check_finite=False)
|
|
174
|
+
except np.linalg.LinAlgError:
|
|
175
|
+
invS = pinv(blocks_S[i])
|
|
176
|
+
|
|
177
|
+
M = blocks_G[i] @ invS
|
|
178
|
+
s, _ = np.linalg.eig(M)
|
|
179
|
+
s_sorted = np.sort(s)[::-1].real
|
|
180
|
+
|
|
181
|
+
return np.sum(s_sorted[:r]) - r
|
|
182
|
+
|
|
183
|
+
results = Parallel(n_jobs=-1)(delayed(_process_block)(i) for i in range(B))
|
|
184
|
+
return np.array(results, dtype=float)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ============================================================
|
|
188
|
+
# ---------------- Dispatcher: choose function ---------------
|
|
189
|
+
# ============================================================
|
|
190
|
+
|
|
191
|
+
def _choose_activity_index_batch(localizer_to_use: str):
|
|
192
|
+
"""
|
|
193
|
+
Return the appropriate activity index function depending on the localizer name.
|
|
194
|
+
"""
|
|
195
|
+
if localizer_to_use == "mai":
|
|
196
|
+
return lambda Gs, Ss, Ts, current_iter, r: _simple_mai_batch(Gs, Ss, current_iter)
|
|
197
|
+
elif localizer_to_use == "mai_mvp":
|
|
198
|
+
return lambda Gs, Ss, Ts, current_iter, r: _mai_mvp_batch(Gs, Ss, r, current_iter)
|
|
199
|
+
else:
|
|
200
|
+
raise ValueError("Allowed: 'mai' and 'mai_mvp'")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ============================================================
|
|
204
|
+
# ---------------- Main function -----------------------------
|
|
205
|
+
# ============================================================
|
|
206
|
+
|
|
207
|
+
def get_activity_index(localizer_to_use: str,
|
|
208
|
+
H: np.ndarray,
|
|
209
|
+
R: np.ndarray,
|
|
210
|
+
N: np.ndarray,
|
|
211
|
+
n_sources_to_localize: int,
|
|
212
|
+
r: int,
|
|
213
|
+
batch_size: int = 16384,
|
|
214
|
+
show_progress: bool = True
|
|
215
|
+
) -> Tuple[List[int], np.ndarray, int, np.ndarray]:
|
|
216
|
+
"""
|
|
217
|
+
Greedy source localization algorithm.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
# --------------------------------------------------------
|
|
221
|
+
# Robust precompute for PSD low-rank R and N (NO Cholesky)
|
|
222
|
+
# --------------------------------------------------------
|
|
223
|
+
# A_R = R^+ H
|
|
224
|
+
# A_N = N^+ H
|
|
225
|
+
# A_TR = R^+ N R^+ H (same object as: pinv(R) @ N @ pinv(R) @ H)
|
|
226
|
+
# --------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
R_pinv = psd_pinv_operator(R, rmax=None, eps_rel=1e-10)
|
|
229
|
+
N_pinv = psd_pinv_operator(N, rmax=None, eps_rel=1e-10)
|
|
230
|
+
|
|
231
|
+
A_R = R_pinv.apply(H)
|
|
232
|
+
A_N = N_pinv.apply(H)
|
|
233
|
+
A_TR = R_pinv.apply(N @ A_R)
|
|
234
|
+
|
|
235
|
+
# Initialization
|
|
236
|
+
n_channels, n_sources = H.shape
|
|
237
|
+
H_sel = np.zeros((n_channels, n_sources_to_localize), dtype=H.dtype)
|
|
238
|
+
S_sel = G_sel = T_sel = None
|
|
239
|
+
index_max: List[int] = []
|
|
240
|
+
act_values: List[float] = []
|
|
241
|
+
|
|
242
|
+
func = _choose_activity_index_batch(localizer_to_use)
|
|
243
|
+
selected_mask = np.zeros(n_sources, dtype=bool)
|
|
244
|
+
all_indices = np.arange(n_sources, dtype=int)
|
|
245
|
+
|
|
246
|
+
need_G = ("mai" in localizer_to_use) or (localizer_to_use == "mpz_mvp")
|
|
247
|
+
need_T = ("mpz" in localizer_to_use)
|
|
248
|
+
|
|
249
|
+
# Greedy selection loop
|
|
250
|
+
for outer_iter in range(n_sources_to_localize):
|
|
251
|
+
best_val = -np.inf
|
|
252
|
+
best_idx = None
|
|
253
|
+
|
|
254
|
+
if show_progress:
|
|
255
|
+
pbar = tqdm(total=n_sources, desc=f"iter {outer_iter+1}/{n_sources_to_localize}")
|
|
256
|
+
|
|
257
|
+
# Process candidates in batches
|
|
258
|
+
for start in range(0, n_sources, batch_size):
|
|
259
|
+
end = min(n_sources, start + batch_size)
|
|
260
|
+
batch_idx = all_indices[start:end]
|
|
261
|
+
valid = ~selected_mask[batch_idx]
|
|
262
|
+
if not np.any(valid):
|
|
263
|
+
if show_progress:
|
|
264
|
+
pbar.update(end - start)
|
|
265
|
+
continue
|
|
266
|
+
batch_idx = batch_idx[valid]
|
|
267
|
+
|
|
268
|
+
# Build block structures
|
|
269
|
+
A_R_batch = A_R[:, batch_idx]
|
|
270
|
+
H_batch = H[:, batch_idx]
|
|
271
|
+
blocks_S = _build_blocks_batch(H_sel[:, :len(index_max)], S_sel, A_R_batch, H_batch)
|
|
272
|
+
|
|
273
|
+
if need_G:
|
|
274
|
+
A_N_batch = A_N[:, batch_idx]
|
|
275
|
+
blocks_G = _build_blocks_batch(H_sel[:, :len(index_max)], G_sel, A_N_batch, H_batch)
|
|
276
|
+
else:
|
|
277
|
+
blocks_G = np.zeros_like(blocks_S)
|
|
278
|
+
|
|
279
|
+
if need_T:
|
|
280
|
+
A_TR_batch = A_TR[:, batch_idx]
|
|
281
|
+
blocks_T = _build_blocks_batch(H_sel[:, :len(index_max)], T_sel, A_TR_batch, H_batch)
|
|
282
|
+
else:
|
|
283
|
+
blocks_T = np.zeros_like(blocks_S)
|
|
284
|
+
|
|
285
|
+
# Compute activity index values for the batch
|
|
286
|
+
vals = func(blocks_G, blocks_S, blocks_T, current_iter=outer_iter + 1, r=r)
|
|
287
|
+
local_best_idx = int(np.argmax(vals))
|
|
288
|
+
local_best_val = float(vals[local_best_idx])
|
|
289
|
+
|
|
290
|
+
# Update global best
|
|
291
|
+
if local_best_val > best_val:
|
|
292
|
+
best_val = local_best_val
|
|
293
|
+
best_idx = int(batch_idx[local_best_idx])
|
|
294
|
+
|
|
295
|
+
if show_progress:
|
|
296
|
+
pbar.update(end - start)
|
|
297
|
+
|
|
298
|
+
if show_progress:
|
|
299
|
+
pbar.close()
|
|
300
|
+
|
|
301
|
+
if best_idx is None:
|
|
302
|
+
break
|
|
303
|
+
|
|
304
|
+
# Save chosen index and its value
|
|
305
|
+
index_max.append(best_idx)
|
|
306
|
+
act_values.append(float(best_val))
|
|
307
|
+
sel_pos = len(index_max) - 1
|
|
308
|
+
H_sel[:, sel_pos:sel_pos+1] = H[:, best_idx:best_idx+1]
|
|
309
|
+
selected_mask[best_idx] = True
|
|
310
|
+
|
|
311
|
+
# Incrementally update block matrices S_sel / G_sel / T_sel
|
|
312
|
+
aR_chosen = A_R[:, best_idx:best_idx+1]
|
|
313
|
+
S_sel = _build_blocks_batch(H_sel[:, :sel_pos+1], S_sel, aR_chosen,
|
|
314
|
+
H[:, best_idx:best_idx+1])[0]
|
|
315
|
+
|
|
316
|
+
if need_G:
|
|
317
|
+
aN_chosen = A_N[:, best_idx:best_idx+1]
|
|
318
|
+
G_sel = _build_blocks_batch(H_sel[:, :sel_pos+1], G_sel, aN_chosen,
|
|
319
|
+
H[:, best_idx:best_idx+1])[0]
|
|
320
|
+
|
|
321
|
+
if need_T:
|
|
322
|
+
aTR_chosen = A_TR[:, best_idx:best_idx+1]
|
|
323
|
+
T_sel = _build_blocks_batch(H_sel[:, :sel_pos+1], T_sel, aTR_chosen,
|
|
324
|
+
H[:, best_idx:best_idx+1])[0]
|
|
325
|
+
|
|
326
|
+
# Collect selected sources
|
|
327
|
+
# Sort and permute act_values to match sorted indices, so the returned tuple is internally self-consistent
|
|
328
|
+
if index_max:
|
|
329
|
+
sort_perm = np.argsort(index_max)
|
|
330
|
+
sorted_sources = [index_max[i] for i in sort_perm]
|
|
331
|
+
H_res = H[:, sorted_sources].copy()
|
|
332
|
+
act_values_sorted = np.array(act_values, dtype=float)[sort_perm]
|
|
333
|
+
else:
|
|
334
|
+
sorted_sources = []
|
|
335
|
+
H_res = np.zeros((n_channels, 0), dtype=H.dtype)
|
|
336
|
+
act_values_sorted = np.array([], dtype=float)
|
|
337
|
+
|
|
338
|
+
# Final summary
|
|
339
|
+
print("\n[Activity Index Result]")
|
|
340
|
+
print(f" Selected indices (greedy discovery order): {index_max}")
|
|
341
|
+
print(f" Selected indices (sorted, matches H_res columns): {sorted_sources}")
|
|
342
|
+
print(f" Index max values (sorted to match H_res/sorted_sources): {act_values_sorted}")
|
|
343
|
+
print(f" Rank parameter (r): {r}\n")
|
|
344
|
+
|
|
345
|
+
return sorted_sources, act_values_sorted, r, H_res
|