-
Notifications
You must be signed in to change notification settings - Fork 34
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[ENH] Reduce compute time for multivariate coherency methods #184
Conversation
It could also be good to update the error message for the invalid cases to better reflect what is now happening in the code: mne-connectivity/mne_connectivity/spectral/epochs_multivariate.py Lines 293 to 301 in 22a3275
Before: "the transformation matrix of the data must be real-valued and contain no NaN or infinity values". |
There are a few runtime errors for MacOS with some NumPy functions, however these do not occur for Ubuntu or Windows. Not immediately clear to me why since all are running Python 3.11.9 with NumPy 1.26.4. Failing tests also include Granger causality methods, where nothing has been changed. Would need to look into this more unless someone has any ideas?? |
The documentation also fails to build due to an error in Hopefully #185 solves this. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Indeed I often resort to using np.linalg.*
over scipy.linalg
exactly for its ability to operate over the last two dimensions of a N-dimensional array
Failure is now related. You probably need to add a |
@larsoner Added macOS-specific filters for the coherency methods alongside your GC filters. Now everything is passing! Just want to clarify there is nothing outstanding?? |
Hi everyone, About this new approach, if I follow the logic correctly and if I did not miss anything, you could simply square root and invert the eigenvalues rather than the entire matrices (which was maybe intended as this is natural for power of matrices). I mean you would change: T[:, :, :n_seeds, :n_seeds] = np.linalg.inv(
np.matmul(
(eigvects * np.expand_dims(np.sqrt(eigvals), (2))),
eigvects.transpose(0, 1, 3, 2),
)
) into T[:, :, :n_seeds, :n_seeds] = (eigvects * np.expand_dims(1./np.sqrt(eigvals), (2))) @ eigvects.transpose(0, 1, 3, 2) For both seed and target. It saves the This change has little impact for few channels (that's where the inverse operator bottleneck is), but for many channels it's a relatively big difference. I observed a consistent decrease in computation time, e.g. with C_r of shape |
@Hugo-W Nice suggestion! Yeah, this approach also works and all regression tests pass. The only difference is how the cases of non-full rank data are handled. In the extreme cases we are using in the unit tests, this is now all being caught as a Even if we do this, I think it's good to keep catching |
Indeed it's probably worth having a |
Added the suggestion of @larsoner to raise Think that's all of the points addressed. |
if (eigvals == 0).any(): # sign of non-full rank data | ||
raise np.linalg.LinAlgError() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if (eigvals == 0).any(): # sign of non-full rank data | |
raise np.linalg.LinAlgError() | |
n_zero = (eigvals == 0).sum() | |
if n_zero: # sign of non-full rank data | |
raise np.linalg.LinAlgError( | |
"Cannot compute inverse square root of rank-deficient matrix " | |
f"with {n_zero}/{len(eigvals)} zero eigenvalue(s)" | |
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, added for both the seed and target eigvals.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just one tiny suggestion to make the raised error more meaningful/informative
EDIT: And a question, too
_gc_marks = [] | ||
if platform.system() == "Darwin" and platform.processor() == "arm": | ||
_coh_marks.extend([ | ||
pytest.mark.filterwarnings("ignore:invalid value encountered in sqrt:") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can this be removed now that there is a raise LinAlgError
in there? Or does it come from another code path?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, the filters for the cohy methods should now be redundant. Have removed and will wait to see if macOS tests pass to be sure.
Thanks @tsbinns ! |
Problem
A major step in computing connectivity for the CaCoh, MIC, and MIM methods is computing a transformation matrix, taken as the inverse square root of the CSD which must be performed in turn for each frequency and time entry of the CSD.
The current approach is to use SciPy's
linalg.fractional_matrix_power()
function, however this is only compatible with 2D arrays, meaning we must loop over all time and frequency entries which not very fast. Unfortunately, there is no equivalent method in NumPy for working with ND arrays (also no equivalent of thelinalg.sqrtm()
function which would be an alternative).The workaround for now was to offer parallelisation of this computation over frequencies, however this is still not as fast as operating on the ND arrays, and also requires a loop over time entries.
Solution
A faster solution can be achieved using a couple of tensor-compatible NumPy functions:
mne-connectivity/mne_connectivity/spectral/epochs_multivariate.py
Lines 335 to 355 in a06353a
For a CSD with 4 channels, 96 frequencies, and no parallelisation, the current approach took ~80 ms to run. This new approach takes ~10 ms. Even with paralellisation, the new approach still offers improvements, as if
n_jobs
is < the number of frequencies some looping over frequency bins will still be involved, and there is the overhead to initialise the workers.Also, it is not a super rigorous check, but running the test suite of
spectral.py
drops the run time from ~30 s to ~20 s on my machine, so there's a noticeable improvement.The result is also identical down to ~1e-16, and the regression tests for checking the consistency of the connectivity results pass without any problems.
The biggest difference is how invalid cases (e.g. non-singular CSDs) are handled. Currently, the function will run in these cases, but NaN/inf/imaginary numbers can be present, which was being checked for. In this new approach, a
LinAlgError
will be raised which we can catch:mne-connectivity/mne_connectivity/spectral/epochs_multivariate.py
Lines 293 to 301 in a06353a
Again, the unit tests for checking these invalid cases are caught also pass without any problems.
Also, if we are not parallelising the function it no longer has to be outside of the class, so we can tidy things up and make the function a class method:
mne-connectivity/mne_connectivity/spectral/epochs_multivariate.py
Lines 303 to 334 in a06353a
Conclusion
Altogether I think this is a very nice improvement that maintains the functionality while giving a big speed up to the CaCoh, MIC, and MIM methods.
Let me know if anyone has any thoughts/comments. Cheers!