-
Notifications
You must be signed in to change notification settings - Fork 1.3k
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] Add tutorial on time-frequency source estimation with STC viewer GUI #10920
Conversation
I think there is some mixing of two concepts here, let me try and make this clearer: Very generally speaking:
Some exceptions that come to mind (@alexrockhill and I have discussed some of these, so I am adding them for clarity):
Now back to the proposal. I would suggest to have a DICS beamformer with this functionality. Meaning: We use Alternatively, we can go with the Hilbert approach, where we would use bandpass-filtered time-domain data. Here the output would be one Hope this will add some clarity with respect to frequency- and time-domain beamforming. |
Ok, I'm totally on the same page with one thing that I don't quite understand. I will make it more of a priority to add the DICS support sooner rather than later. The one thing I don't understand:
Why is it appropriate to use the Hilbert transform and not any other time-frequency decomposition? You can get amplitude and not power from Morlet wavelets or you can square the Hilbert amplitude and get power right? I'm not sure what's different about the Hilbert transform compared to other time-frequency methods. From our earlier conversation, I thought you could apply a LCMV or DICS filter with the tradeoff being that the DICS is discontinuous in time and the LCMV is discontinuous in frequencies so from that I would think it would be nice to be able to use either depending on your analysis. |
Let me be a bit more specific, hope this will make it clearer. My main reason: Thus, for the Hilbert-case (which operates on a bandpass-filtered time-domain signal), we use an LCMV (which uses a covariance matrix based on the same bandpass-filtered signal), while for a TFR reconstruction, we should rather use a DICS beamformer that is based on a cross-spectral density matrix that uses power estimates of the same frequency resolution. Furthermore, this way we do not have to worry about some of the not-shared features between LCMV and DICS (e.g. complex valued filters).
Remember that the filter as such is never time-resolved (not to be mixed up with time-domain!), the time-resolution is only recovered in the application of the filter to the data (where, theoretically, it is possible to apply both LCMV and DICS filters to a time-resolved signal). |
Because the
I don't follow the rest of the argument about why a collection of bandpassed Hilbert transformed time-frequency data is any different than any other time-frequency decomposition. I understand everything you said and I think that's all reasonable but I don't understand how it applies to this point. You could take the covariance and not cross-spectral density of the TFR epochs and I'm not convinced that's any different than doing that for the Hilbert method. Thanks for writing back, sorry for a few more questions :) |
The DICS and the LCMV spatial filter are not different from each other in terms of how much data they need to go into the CSD or covariance matrix (i.e. the time interval the filter is built on). Basically, it boils down to: why would you mix-and-match if staying within the time-domain or the frequency-domain will give you a methodologically more sound and cleaner result? |
I'm still not following, you have to mix and match because you want time-frequency resolved data so you can either 1) convert to time-frequency with many bandpass filtered signals and apply Hilbert or use Morlet wavelets and then compute the covariance matrix and apply the LCMV beamformer or 2) compute the DICS beamformer on sliding time-windows using cross-spectral density, right? To me the bandpass filter and sliding time windows are unideal because they inexactly resolve in frequency and time respectively. So wouldn't you want to select the more reasonable one for your use-case or compare results? EDIT: After re-reading, it sounds like you associate the Hilbert transform with the time domain in some way that is different than Morlet wavelets. I would question this assumption: they're different methods but fundamentally they are trying to compute the same thing; the time-frequency amplitude or power. I've read and understand what you're saying but I don't agree that the Hilbert transform is somehow more closely associated with the time domain and therefore more cleanly associated with using an LCMV beamformer. Fundamentally, I think it's an empirical question of which works better based on that method's tradeoffs with estimating time-frequencies (i.e. choosing things like the bandwidth of the filter for Hilbert and the number of cycles for Morlet wavelets). Undoubtably, the practical experience of someone who uses these methods is very valuable and should be used to inform which method gets used or highlighted in the MNE tutorial, but I think it's important not to conflate what is mathematically inconsistent with what empirically just doesn't work as well. As I mentioned in the meeting, I think something that would really improve the methods in M/EEG in general, not just in MNE, is a really good set of decoding datasets to test methodological choices on. Maybe some of the mne-bids pipeline data that @agramfort is working on would be good for that but it's a bit outside of GSoC. Yes, if some choice of method works well for one dataset that's not super helpful in all cases, but if some methodological choice is consistently better across several datasets, I think that's much better evidence than intuition based on prior experience, again, although that is super helpful too. I don't want to dwell too much on beamforming choices but this is super relevant to the API; should the LCMV beamformer apply method take in an |
Ok @britta-wstnr and @agramfort, if you want to try running through this tutorial, I think now would give a good indication of the direction I think we should go (and that we've agreed upon). The main reason I bring this up is that the LCMV (of Hilbert data) and DICS apply functions returns a list of list of stcs but the LCMV is nested frequencies first and then epochs and then the stcs are shape As for whether it's okay to use Morlet wavelets instead of Hilbert and why you can't use power as input to a minimum norm estimation, I think we should discuss at the next meeting and I'm looking forward to learning about how these work :) It's not crucial that everything be resolved with the source estimation before moving forward (it really needs to be optimized for parallelization also), I just wanted to make sure the class worked so that it could be used as input to the GUI without having to be changed. I have no idea what the data looks like and I haven't invented any new methods, just used the old ones, so I assume that it will be reasonable but a time-frequency viewer is sorely needed with this high of a dimension of data; it's just very hard to look at. Will have a reasonable screenshot soon! EDIT: Also very likely the final minimum norm source estimation method is wrong since it's done with wavelets only in EDIT2: Actually I tried |
Notes per method: 1a) The LCMV on the Hilbert transformed data works great but is quite slow because the bandpass filter has to be computed on the raw data before epoching. |
@alexrockhill it would help to go over a rendered doc page / tutorial to see the user perspective on this. Is there any? Sorry if I let you babysit me here ... |
There will be soon since #10940 merged, it was too much memory, causing a segfault in circle but it runs locally. Let me see about now, otherwise I'll comment out 2/3 methods (LCMV, DICS, MNE). |
Okay, I can only reiterate my points from before:
I am still opposed to applying an LCMV beamformer to a Morlet wavelet resolved TFR. Of course, you could show this actually works way better than the DICS beamformer on a Morlet TFR - but that would be a whole project (you cannot just show it on one data set and be done but need carefully simulated data etc). Until then: let's not confuse our users. |
That's not my understanding, here is the API, https://mne.tools/stable/generated/mne.time_frequency.tfr_array_morlet.html, you can decimate but by default there are the same number of time steps. And you can decimate the Hilbert in the same way.
I agree that the CSD matrix uses a wavelet and thus matching CSD methods with your filter and and time-series object that you apply it to seems like the only logically consistent approach but I don't know what this has to do with LCMV, that seems like a misleading analogy. The Hilbert transform itself doesn't use a time-domain filter, you first filter your data before taking the analytic representation of the data which is very similar to a Fourier transform. Both Hilbert and Morlet methods generally window data in frequency (via the filter before Hilbert) and time-frequency respectively and do a Fourier transform or Fourier-transform-like operation to compute the amplitude or power so I don't see the big difference. Happy to talk more in person, I do think by iterating we've clarified where each of our thought processes are coming from but it seems like I haven't done a good job convincing you so maybe we should see if other people weighing in helps us get on the same page. EDIT: Maybe @wmvanvliet might have a minute to chime in here since you've done a lot of the DICS development. The question is: Is it okay to use Morlet wavelets instead of narrow bandpass filtered epoch data with a Hilbert transform as input to an LCMV beamformer? @britta-wstnr 's and my points of view is summarized above, if you have a few minutes. |
Here's the artifact @agramfort https://output.circle-artifacts.com/output/job/10323476-dfd6-4c9a-86c0-58228c1aa59f/artifacts/0/dev/auto_tutorials/inverse/55_tfr_stc.html?highlight=55_tfr, sorry I know it's late your time. It doesn't have many visuals because that has to be done when the GUI is built but basically:
|
Fixes #10721. I only mentioned passingly in a comment to a review from @britta-wstnr that I would like to keep the epochs dimension for my own analysis at single-trial resolution if that's not too much of a headache for everyone else. Also, I didn't inherit |
@agramfort and @britta-wstnr , I get the following stack trace when I try to use complex phase-amplitude and not power:
Any thoughts on how to fix this? EDIT: I'm pushing a hacked version for now that uses single dipole inversion and suppressed an error so that you can see the changes but it definitely needs to be fixed. The minimum norm source estimation worked with no errors also by the way. |
Ok, I couldn't figure out why the eigenvalues were not all positive but it has to do with the data covariance matrix which is positive definite for the CSD and is complex as well in that case so complex numbers are supported, it's just an issue with the conditioning of the matrix. The CSD matrix had a much larger diagonal than off diagonal entries for the real part compared to the covariance of the Hilbert which had larger off the diagonal than on the diagonal. I'm not exactly sure what the issue is with the conditioning though, I'm a bit stuck. As I'm writing this, I'm thinking that real part of the covariance of the Hilbert should be positive since it's amplitude and I didn't check that this was true, I'll look into that next. |
hum hard to say without debugging. I think the code uses eigh which should also work with a complex valued matrix. Did you see how negative the eigenvalues are? Can you check at least that the input matrix is hermitian is A == A.H ? |
DICS == LCMV applied to a TFR (wavelets, multitaper, hilbert, whatever). A "tfr covariance" == CSD. So if you do LCMV using a "covariance matrix" derived from some sort of time-frequency decomposition, you are doing DICS. Literally, DICS and LCMV use the same code path ( What you are doing here is event-related DICS (Laaksonen, Kujala and Salmelin 2007) which we never got around to implementing. It looks like you are doing this with the |
That was my understanding but now that you say that maybe it doesn't make sense to have the sliding windows but rather to only use the compute_covariance function on the epochs_tfr. |
They are large and negative, the largest was -13000 or so. I'll have to check on Hermitian. |
I think it is best to follow published methods for the main MNE-Python package. So I recommend following whatever approach they used in the paper. A sliding window will always have to be applied somewhere. When computing the TFR, when computing the CSD, or when applying the beamformer... I guess in the end it doesn't matter where we apply it, or even if we apply multiple sliding windows. |
Could you please modify the |
The LCMV beamformer uses a covariance matrix based on time-domain data. The Hilbert beamformer, as it was defined by my former supervisor @sarangnemo, computes a covariance matrix based on filtered time-domain data and then uses this in the LCMV beamformer. I would vote for not making the focus to invent a new beamformer, but keep with what is used in the literature already - and for your GSOC just use synthetic input as suggested by @agramfort. The details of the computation of the beamformer should not influence the layout of the source space object. |
What I would do is computed on CSD matrix across power for the whole time period - and then apply this time-step by time-step --- as you would with an LCMV beamformer that you apply to an evoked. |
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.
I will try and check out your changes and the behavior of the GUI.
There seem to be a lot of changes as per your latest comments, and I am not sure what they address - are they directly related to reviews or are they adding new functionality?
It's hard for me to see what changed because the "View changes since last review" is broken for me here (probably due to rebase or force-push?) and there is a lot of commits and lines of code to check out.
Thus, this will take me some time to get through - could you share a brief list of what functionalities you changed/added (if any) beyond the lists/generator and cosmetic fixes we had asked for?
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.
@alexrockhill here is a batch comments. I can certainly do another pass next week end. Can you have rebasing your PRs? It forces me to do reset --hard and we squash merge PRs anyway.
Regarding UI
It seems the min is not used over the MRI slice. Any voxel that is less that min should be transparent for me so I see the MRI. Here it's always black so I never see the MRI.
🙏
stc_base, freqs = apply_dics_csd(csd_baseline.mean(), filters) | ||
stc_act, freqs = apply_dics_csd(csd_ers.mean(), filters) | ||
stc_base, freqs = apply_dics_csd(baseline_csd.mean(), filters) | ||
stc_act, freqs = apply_dics_csd(ers_csd.mean(), filters) |
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.
one reason I would rather use csd_ers
rather than ers_csd
is that when I type I prefer to autocomplete based on the type of object. Basically all variables that are of type csd start with csd_ so csd_ gives me what I want.
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.
Sounds good, no problem, again, I was just changing it to be consistent, there was csd_baseline
and active_cov
etc. it would just be nice if the one that was first was consistent. Again, not trying to change all of MNE but just to be consistent in this tutorial would be nice. I will change those to the type first and the descriptor second like you're asking.
mne/gui/__init__.py
Outdated
stc_data['re'] = stc.data.real * 1e16 | ||
stc_data['im'] = stc.data.imag * 1e16 |
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.
where is this 1e16 coming from?
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 you cast to integers without rescaling, you mostly get zeros so this is just so that you can see your data but use integers for speed like is often done for MRIs.
Ok, I will stop rebasing for merge conflicts. @britta-wstnr, the changes were from the request that @drammock and @agramfort had that the function take in the list of lists of stcs directly. If that is done without modification, it's unusable on my computer at least, it's far too slow. So I just changed to put the casting to integers in the function. That's where the 1e16 etc comes from @agramfort also. Making the suggested changes now. Also, I empathize with the added overhead for reviewing a PR but I wouldn't have touched the comments if they weren't inconsistent within that tutorial (some inline comments say |
I tried with the typecasting to get everything faster and it was working, it just adds a lot of complexity so I thought better of it for now, maybe will revisit later. This version should pass all the tests and work well so long as you have a decently powered computer. |
…vely floating point version
I took a minute and fixed the integer casting, it's not really doable for ITC, that was a hangup, that will have to be cast to floating point numbers for the whole data so that will be a limitation for larger datasets. This version was very fast on my machine so I think it would work if there were more epochs and/or more frequencies which I think is pretty important for this scaling well. |
@agramfort ready for another review? Failures are unrelated I think |
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.
thx @alexrockhill for the updates !
here are a few comments on the code.
now on the GUI I don't know if it's only me but the time and frequency sliders are quickly messed up in their locations when I click on the 2d TFR image at the bottom. Are the sliders and the cross in the image allows consistent at your end?
also do you know what takes the most time when I click on TFR image it can take 3s to update. Is it for you the same? what slows the GUI? data access, copies? matplotlib?
🙏
vol_src = mne.setup_volume_source_space( | ||
subject=subject, subjects_dir=subjects_dir, surface=surface, | ||
pos=10, add_interpolator=False) # just for speed! | ||
|
||
conductivity = (0.3,) # one layer for MEG | ||
model = mne.make_bem_model(subject=subject, ico=3, # just for speed | ||
conductivity=conductivity, | ||
subjects_dir=subjects_dir) | ||
bem = mne.make_bem_solution(model) | ||
|
||
trans = fwd['info']['mri_head_t'] | ||
vol_fwd = mne.make_forward_solution( | ||
raw.info, trans=trans, src=vol_src, bem=bem, meg=True, eeg=True, | ||
mindist=5.0, n_jobs=1, verbose=True) |
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.
ok fair enough
'not yet supported so `inst` is required') | ||
if not isinstance(data, np.ndarray) or data.ndim != 5: | ||
raise ValueError('`data` must be an array of dimensions ' | ||
'(n_epochs, n_sources, n_ori, n_freqs, n_times)') |
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.
in the header docstring above you say
"""View a volume time and/or frequency source time course estimate.
I therefore wondering if it should not be a and
only.
Do you have a example of usage without frequency?
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.
Yes, it works just fine without frequency
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.
There is an example in the tests, do you want to add it to a tutorial or example?
elif self._epoch_idx == 'Average Power': | ||
if self._data.dtype == COMPLEX_DTYPE: | ||
stc_data = np.sum(_int_complex_conj( | ||
self._data) // self._data.shape[0], axis=0) | ||
else: | ||
stc_data = (self._data * self._data.conj()).real.mean(axis=0) | ||
elif self._epoch_idx == 'ITC': | ||
if self._data.dtype == COMPLEX_DTYPE: | ||
stc_data = self._data['re'].astype(np.complex64) + \ | ||
1j * self._data['im'] | ||
stc_data = np.abs((stc_data / np.abs(stc_data)).mean( | ||
axis=0)) | ||
else: | ||
stc_data = np.abs((self._data / np.abs(self._data)).mean( | ||
axis=0)) |
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.
would there be a way to avoid having some method logic in the GUI? basically we could have other stuff than ITC, or average_power (think real part of the coherence etc.) and I am a bit worried to see this implemented in a GUI code
just tell me what you think
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.
The reason to implement it here is that it's really slow using float and it's relatively easy and much faster to use integers. There's not a cleanly abstracted function for ITC in tfr.py
anyway so that would need to be refactored. We could make _itc
and _itc_int
functions in tfr.py
if you want, I just was trying to keep the number of files touched low.
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.
It doesn't look like the most straightforward abstraction/refactor from here since it's looped
# Inter-trial phase locking is apparently computed per taper...
if 'itc' in output:
plf = np.zeros((n_freqs, n_times), dtype=np.complex128)
# Loop across epochs
for epoch_idx, tfr in enumerate(coefs):
# Transform complex values
if output in ['power', 'avg_power']:
tfr = (tfr * tfr.conj()).real # power
elif output == 'phase':
tfr = np.angle(tfr)
elif output == 'avg_power_itc':
tfr_abs = np.abs(tfr)
plf += tfr / tfr_abs # phase
tfr = tfr_abs ** 2 # power
elif output == 'itc':
plf += tfr / np.abs(tfr) # phase
continue # not need to stack anything else than plf
# Stack or add
if ('avg_' in output) or ('itc' in output):
tfrs += tfr
elif output in ['complex', 'phase'] and method == 'multitaper':
tfrs[taper_idx, epoch_idx] += tfr
else:
tfrs[epoch_idx] += tfr
mne/gui/_vol_stc.py
Outdated
if self._baseline != 'None' and self._epoch_idx != 'ITC': | ||
if self._data.dtype in (COMPLEX_DTYPE, np.int16): | ||
stc_mean = np.sum(stc_data // stc_data.shape[0], axis=0) | ||
else: | ||
stc_mean = stc_data.mean(axis=-1, keepdims=True) | ||
if self._baseline == 'Gain': | ||
if self._data.dtype in (COMPLEX_DTYPE, np.int16): | ||
stc_data = stc_data // stc_mean | ||
else: | ||
stc_data = stc_data / stc_mean | ||
else: | ||
assert self._baseline == 'Subtraction' | ||
if self._data.dtype in (COMPLEX_DTYPE, np.int16): | ||
neg_mask = stc_data < (-RANGE_VALUE + stc_mean) | ||
pos_mask = stc_data > (RANGE_VALUE + 1 + stc_mean) | ||
stc_data -= stc_mean | ||
stc_data[neg_mask] = -RANGE_VALUE | ||
stc_data[pos_mask] = RANGE_VALUE - 1 | ||
else: | ||
stc_data -= stc_mean |
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.
same remark we have some shared functions to compute baseline in different ways. I would try to rely on these has much as possible
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.
I looked into these, they require a time range and I just hadn't implemented it yet. I should just add a tmin and tmax for the baseline
Hmm, these seem like they're in sync to me. Let me know if this is still an issue.
It was that the time was accessing data and updating the images and then updating frequency did the same. I fixed it so that it only updated once. |
Copying a minimally reproducible script to run the viewer:
Looks good to me |
Merge reverted, feel free to respond to above comments @agramfort and then presumably @alexrockhill can open a second PR with the same changeset for any additional commits |
* upstream/main: Revert "[ENH] Add tutorial on time-frequency source estimation with STC viewer GUI" (mne-tools#11350) [ENH] Add tutorial on time-frequency source estimation with STC viewer GUI (mne-tools#10920) FIX: Fix example (mne-tools#11348) Add mastodon link (mne-tools#11347) fix default n_fft for spectrum plots (mne-tools#11345)
Related to #10721, GSoC.
So I realize there aren't really any tutorials on time-frequency source space resolution (there is DICS but that's just frequency in the tutorial) so I think this fills a very important gap in MNE functionality. Also, since it is the most complete use case (you can always average across time or trials) it covers a lot of other use cases so I'm pretty excited about adding this.
Basically, time-frequency resolved MEG data is filtered using an LCMV beamformer. This yields a list of lists of stcs where the outer layer is frequencies and the inner layer is trials. The above referenced issue is the discussion for why this is hard to keep track of and could use the introduction of a new object. There's a lot that could be done but the priorities in order are:
pyvistaqt
first)TFRVolSourceEstimate
class to hold the list of list stcs and be returned byapply_lcmv_epochs_tfr
LCMV
. Making a new class for each existing source estimate class seems like a ton of overhead but potentially the more flexible source vertices+time+frequency+trials stc classes could supersede the source vertices+time source estimates and those could just be the flat case of the more general source estimates, that might be a huge refactoring though since it's so central to MNE.Now is a great time to take five minutes to review the future direction if you have a minute. I will be out of town for the next few days so there won't be much movement.
cc @britta-wstnr @larsoner @drammock