initial commit

This commit is contained in:
2025-08-19 09:13:22 -07:00
parent 28464811d6
commit 0977a3e14d
820 changed files with 1003358 additions and 2 deletions

View File

@@ -0,0 +1,8 @@
# Authors: The MNE-Python contributors.
# License: BSD-3-Clause
# Copyright the MNE-Python contributors.
"""Beamformers for source localization."""
import lazy_loader as lazy
(__getattr__, __dir__, __all__) = lazy.attach_stub(__name__, __file__)

View File

@@ -0,0 +1,34 @@
__all__ = [
"Beamformer",
"apply_dics",
"apply_dics_csd",
"apply_dics_epochs",
"apply_dics_tfr_epochs",
"apply_lcmv",
"apply_lcmv_cov",
"apply_lcmv_epochs",
"apply_lcmv_raw",
"make_dics",
"make_lcmv",
"make_lcmv_resolution_matrix",
"rap_music",
"read_beamformer",
"trap_music",
]
from ._compute_beamformer import Beamformer, read_beamformer
from ._dics import (
apply_dics,
apply_dics_csd,
apply_dics_epochs,
apply_dics_tfr_epochs,
make_dics,
)
from ._lcmv import (
apply_lcmv,
apply_lcmv_cov,
apply_lcmv_epochs,
apply_lcmv_raw,
make_lcmv,
)
from ._rap_music import rap_music, trap_music
from .resolution_matrix import make_lcmv_resolution_matrix

View File

@@ -0,0 +1,603 @@
"""Functions shared between different beamformer types."""
# Authors: The MNE-Python contributors.
# License: BSD-3-Clause
# Copyright the MNE-Python contributors.
from copy import deepcopy
import numpy as np
from .._fiff.proj import Projection, make_projector
from ..cov import Covariance, make_ad_hoc_cov
from ..forward.forward import _restrict_forward_to_src_sel, is_fixed_orient
from ..minimum_norm.inverse import _get_vertno, _prepare_forward
from ..source_space._source_space import label_src_vertno_sel
from ..time_frequency.csd import CrossSpectralDensity
from ..utils import (
_check_option,
_check_src_normal,
_import_h5io_funcs,
_pl,
_reg_pinv,
_sym_mat_pow,
check_fname,
logger,
verbose,
warn,
)
def _check_proj_match(proj, filters):
"""Check whether SSP projections in data and spatial filter match."""
proj_data, _, _ = make_projector(proj, filters["ch_names"])
if not np.allclose(
proj_data, filters["proj"], atol=np.finfo(float).eps, rtol=1e-13
):
raise ValueError(
"The SSP projections present in the data "
"do not match the projections used when "
"calculating the spatial filter."
)
def _check_src_type(filters):
"""Check whether src_type is in filters and set custom warning."""
if "src_type" not in filters:
filters["src_type"] = None
warn_text = (
"The spatial filter does not contain src_type and a robust "
"guess of src_type is not possible without src. Consider "
"recomputing the filter."
)
return filters, warn_text
def _prepare_beamformer_input(
info,
forward,
label=None,
pick_ori=None,
noise_cov=None,
rank=None,
pca=False,
loose=None,
combine_xyz="fro",
exp=None,
limit=None,
allow_fixed_depth=True,
limit_depth_chs=False,
):
"""Input preparation common for LCMV, DICS, and RAP-MUSIC."""
_check_option("pick_ori", pick_ori, ("normal", "max-power", "vector", None))
# Restrict forward solution to selected vertices
if label is not None:
_, src_sel = label_src_vertno_sel(label, forward["src"])
forward = _restrict_forward_to_src_sel(forward, src_sel)
if loose is None:
loose = 0.0 if is_fixed_orient(forward) else 1.0
# TODO: Deduplicate with _check_one_ch_type, should not be necessary
# (DICS hits this code path, LCMV does not)
if noise_cov is None:
noise_cov = make_ad_hoc_cov(info, std=1.0)
(
forward,
info_picked,
gain,
_,
orient_prior,
_,
trace_GRGT,
noise_cov,
whitener,
) = _prepare_forward(
forward,
info,
noise_cov,
"auto",
loose,
rank=rank,
pca=pca,
use_cps=True,
exp=exp,
limit_depth_chs=limit_depth_chs,
combine_xyz=combine_xyz,
limit=limit,
allow_fixed_depth=allow_fixed_depth,
)
is_free_ori = not is_fixed_orient(forward) # could have been changed
nn = forward["source_nn"]
if is_free_ori: # take Z coordinate
nn = nn[2::3]
nn = nn.copy()
vertno = _get_vertno(forward["src"])
if forward["surf_ori"]:
nn[...] = [0, 0, 1] # align to local +Z coordinate
if pick_ori is not None and not is_free_ori:
raise ValueError(
f"Normal or max-power orientation (got {pick_ori!r}) can only be picked "
"when a forward operator with free orientation is used."
)
if pick_ori == "normal" and not forward["surf_ori"]:
raise ValueError(
"Normal orientation can only be picked when a forward operator oriented in "
"surface coordinates is used."
)
_check_src_normal(pick_ori, forward["src"])
del forward, info
# Undo the scaling that MNE prefers
scale = np.sqrt((noise_cov["eig"] > 0).sum() / trace_GRGT)
gain /= scale
if orient_prior is not None:
orient_std = np.sqrt(orient_prior)
else:
orient_std = np.ones(gain.shape[1])
# Get the projector
proj, _, _ = make_projector(info_picked["projs"], info_picked["ch_names"])
return (is_free_ori, info_picked, proj, vertno, gain, whitener, nn, orient_std)
def _reduce_leadfield_rank(G):
"""Reduce the rank of the leadfield."""
# decompose lead field
u, s, v = np.linalg.svd(G, full_matrices=False)
# backproject, omitting one direction (equivalent to setting the smallest
# singular value to zero)
G = np.matmul(u[:, :, :-1], s[:, :-1, np.newaxis] * v[:, :-1, :])
return G
def _sym_inv_sm(x, reduce_rank, inversion, sk):
"""Symmetric inversion with single- or matrix-style inversion."""
if x.shape[1:] == (1, 1):
with np.errstate(divide="ignore", invalid="ignore"):
x_inv = 1.0 / x
x_inv[~np.isfinite(x_inv)] = 1.0
else:
assert x.shape[1:] == (3, 3)
if inversion == "matrix":
x_inv = _sym_mat_pow(x, -1, reduce_rank=reduce_rank)
# Reapply source covariance after inversion
x_inv *= sk[:, :, np.newaxis]
x_inv *= sk[:, np.newaxis, :]
else:
# Invert for each dipole separately using plain division
diags = np.diagonal(x, axis1=1, axis2=2)
assert not reduce_rank # guaranteed earlier
with np.errstate(divide="ignore"):
diags = 1.0 / diags
# set the diagonal of each 3x3
x_inv = np.zeros_like(x)
for k in range(x.shape[0]):
this = diags[k]
# Reapply source covariance after inversion
this *= sk[k] * sk[k]
x_inv[k].flat[::4] = this
return x_inv
def _compute_beamformer(
G,
Cm,
reg,
n_orient,
weight_norm,
pick_ori,
reduce_rank,
rank,
inversion,
nn,
orient_std,
whitener,
):
"""Compute a spatial beamformer filter (LCMV or DICS).
For more detailed information on the parameters, see the docstrings of
`make_lcmv` and `make_dics`.
Parameters
----------
G : ndarray, shape (n_dipoles, n_channels)
The leadfield.
Cm : ndarray, shape (n_channels, n_channels)
The data covariance matrix.
reg : float
Regularization parameter.
n_orient : int
Number of dipole orientations defined at each source point
weight_norm : None | 'unit-noise-gain' | 'nai'
The weight normalization scheme to use.
pick_ori : None | 'normal' | 'max-power'
The source orientation to compute the beamformer in.
reduce_rank : bool
Whether to reduce the rank by one during computation of the filter.
rank : dict | None | 'full' | 'info'
See compute_rank.
inversion : 'matrix' | 'single'
The inversion scheme to compute the weights.
nn : ndarray, shape (n_dipoles, 3)
The source normals.
orient_std : ndarray, shape (n_dipoles,)
The std of the orientation prior used in weighting the lead fields.
whitener : ndarray, shape (n_channels, n_channels)
The whitener.
Returns
-------
W : ndarray, shape (n_dipoles, n_channels)
The beamformer filter weights.
"""
_check_option(
"weight_norm",
weight_norm,
["unit-noise-gain-invariant", "unit-noise-gain", "nai", None],
)
# Whiten the data covariance
Cm = whitener @ Cm @ whitener.T.conj()
# Restore to properly Hermitian as large whitening coefs can have bad
# rounding error
Cm[:] = (Cm + Cm.T.conj()) / 2.0
assert Cm.shape == (G.shape[0],) * 2
s, _ = np.linalg.eigh(Cm)
if not (s >= -s.max() * 1e-7).all():
# This shouldn't ever happen, but just in case
warn(
"data covariance does not appear to be positive semidefinite, "
"results will likely be incorrect"
)
# Tikhonov regularization using reg parameter to control for
# trade-off between spatial resolution and noise sensitivity
# eq. 25 in Gross and Ioannides, 1999 Phys. Med. Biol. 44 2081
Cm_inv, loading_factor, rank = _reg_pinv(Cm, reg, rank)
assert orient_std.shape == (G.shape[1],)
n_sources = G.shape[1] // n_orient
assert nn.shape == (n_sources, 3)
logger.info(f"Computing beamformer filters for {n_sources} source{_pl(n_sources)}")
n_channels = G.shape[0]
assert n_orient in (3, 1)
Gk = np.reshape(G.T, (n_sources, n_orient, n_channels)).transpose(0, 2, 1)
assert Gk.shape == (n_sources, n_channels, n_orient)
sk = np.reshape(orient_std, (n_sources, n_orient))
del G, orient_std
_check_option("reduce_rank", reduce_rank, (True, False))
# inversion of the denominator
_check_option("inversion", inversion, ("matrix", "single"))
if (
inversion == "single"
and n_orient > 1
and pick_ori == "vector"
and weight_norm == "unit-noise-gain-invariant"
):
raise ValueError(
'Cannot use pick_ori="vector" with inversion="single" and '
'weight_norm="unit-noise-gain-invariant"'
)
if reduce_rank and inversion == "single":
raise ValueError(
'reduce_rank cannot be used with inversion="single"; '
'consider using inversion="matrix" if you have a '
"rank-deficient forward model (i.e., from a sphere "
"model with MEG channels), otherwise consider using "
"reduce_rank=False"
)
if n_orient > 1:
_, Gk_s, _ = np.linalg.svd(Gk, full_matrices=False)
assert Gk_s.shape == (n_sources, n_orient)
if not reduce_rank and (Gk_s[:, 0] > 1e6 * Gk_s[:, 2]).any():
raise ValueError(
"Singular matrix detected when estimating spatial filters. "
"Consider reducing the rank of the forward operator by using "
"reduce_rank=True."
)
del Gk_s
#
# 1. Reduce rank of the lead field
#
if reduce_rank:
Gk = _reduce_leadfield_rank(Gk)
def _compute_bf_terms(Gk, Cm_inv):
bf_numer = np.matmul(Gk.swapaxes(-2, -1).conj(), Cm_inv)
bf_denom = np.matmul(bf_numer, Gk)
return bf_numer, bf_denom
#
# 2. Reorient lead field in direction of max power or normal
#
if pick_ori == "max-power":
assert n_orient == 3
_, bf_denom = _compute_bf_terms(Gk, Cm_inv)
if weight_norm is None:
ori_numer = np.eye(n_orient)[np.newaxis]
ori_denom = bf_denom
else:
# compute power, cf Sekihara & Nagarajan 2008, eq. 4.47
ori_numer = bf_denom
# Cm_inv should be Hermitian so no need for .T.conj()
ori_denom = np.matmul(
np.matmul(Gk.swapaxes(-2, -1).conj(), Cm_inv @ Cm_inv), Gk
)
ori_denom_inv = _sym_inv_sm(ori_denom, reduce_rank, inversion, sk)
ori_pick = np.matmul(ori_denom_inv, ori_numer)
assert ori_pick.shape == (n_sources, n_orient, n_orient)
# pick eigenvector that corresponds to maximum eigenvalue:
eig_vals, eig_vecs = np.linalg.eig(ori_pick.real) # not Hermitian!
# sort eigenvectors by eigenvalues for picking:
order = np.argsort(np.abs(eig_vals), axis=-1)
# eig_vals = np.take_along_axis(eig_vals, order, axis=-1)
max_power_ori = eig_vecs[np.arange(len(eig_vecs)), :, order[:, -1]]
assert max_power_ori.shape == (n_sources, n_orient)
# set the (otherwise arbitrary) sign to match the normal
signs = np.sign(np.sum(max_power_ori * nn, axis=1, keepdims=True))
signs[signs == 0] = 1.0
max_power_ori *= signs
# Compute the lead field for the optimal orientation,
# and adjust numer/denom
Gk = np.matmul(Gk, max_power_ori[..., np.newaxis])
n_orient = 1
else:
max_power_ori = None
if pick_ori == "normal":
Gk = Gk[..., 2:3]
n_orient = 1
#
# 3. Compute numerator and denominator of beamformer formula (unit-gain)
#
bf_numer, bf_denom = _compute_bf_terms(Gk, Cm_inv)
assert bf_denom.shape == (n_sources,) + (n_orient,) * 2
assert bf_numer.shape == (n_sources, n_orient, n_channels)
del Gk # lead field has been adjusted and should not be used anymore
#
# 4. Invert the denominator
#
# Here W is W_ug, i.e.:
# G.T @ Cm_inv / (G.T @ Cm_inv @ G)
bf_denom_inv = _sym_inv_sm(bf_denom, reduce_rank, inversion, sk)
assert bf_denom_inv.shape == (n_sources, n_orient, n_orient)
W = np.matmul(bf_denom_inv, bf_numer)
assert W.shape == (n_sources, n_orient, n_channels)
del bf_denom_inv, sk
#
# 5. Re-scale filter weights according to the selected weight_norm
#
# Weight normalization is done by computing, for each source::
#
# W_ung = W_ug / sqrt(W_ug @ W_ug.T)
#
# with W_ung referring to the unit-noise-gain (weight normalized) filter
# and W_ug referring to the above-calculated unit-gain filter stored in W.
if weight_norm is not None:
# Three different ways to calculate the normalization factors here.
# Only matters when in vector mode, as otherwise n_orient == 1 and
# they are all equivalent.
#
# In MNE < 0.21, we just used the Frobenius matrix norm:
#
# noise_norm = np.linalg.norm(W, axis=(1, 2), keepdims=True)
# assert noise_norm.shape == (n_sources, 1, 1)
# W /= noise_norm
#
# Sekihara 2008 says to use sqrt(diag(W_ug @ W_ug.T)), which is not
# rotation invariant:
if weight_norm in ("unit-noise-gain", "nai"):
noise_norm = np.matmul(W, W.swapaxes(-2, -1).conj()).real
noise_norm = np.reshape( # np.diag operation over last two axes
noise_norm, (n_sources, -1, 1)
)[:, :: n_orient + 1]
np.sqrt(noise_norm, out=noise_norm)
noise_norm[noise_norm == 0] = np.inf
assert noise_norm.shape == (n_sources, n_orient, 1)
W /= noise_norm
else:
assert weight_norm == "unit-noise-gain-invariant"
# Here we use sqrtm. The shortcut:
#
# use = W
#
# ... does not match the direct route (it is rotated!), so we'll
# use the direct one to match FieldTrip:
use = bf_numer
inner = np.matmul(use, use.swapaxes(-2, -1).conj())
W = np.matmul(_sym_mat_pow(inner, -0.5), use)
noise_norm = 1.0
if weight_norm == "nai":
# Estimate noise level based on covariance matrix, taking the
# first eigenvalue that falls outside the signal subspace or the
# loading factor used during regularization, whichever is largest.
if rank > len(Cm):
# Covariance matrix is full rank, no noise subspace!
# Use the loading factor as noise ceiling.
if loading_factor == 0:
raise RuntimeError(
"Cannot compute noise subspace with a full-rank "
"covariance matrix and no regularization. Try "
"manually specifying the rank of the covariance "
"matrix or using regularization."
)
noise = loading_factor
else:
noise, _ = np.linalg.eigh(Cm)
noise = noise[-rank]
noise = max(noise, loading_factor)
W /= np.sqrt(noise)
W = W.reshape(n_sources * n_orient, n_channels)
logger.info("Filter computation complete")
return W, max_power_ori
def _compute_power(Cm, W, n_orient):
"""Use beamformer filters to compute source power.
Parameters
----------
Cm : ndarray, shape (n_channels, n_channels)
Data covariance matrix or CSD matrix.
W : ndarray, shape (nvertices*norient, nchannels)
Beamformer weights.
Returns
-------
power : ndarray, shape (nvertices,)
Source power.
"""
n_sources = W.shape[0] // n_orient
Wk = W.reshape(n_sources, n_orient, W.shape[1])
source_power = np.trace(
(Wk @ Cm @ Wk.conj().transpose(0, 2, 1)).real, axis1=1, axis2=2
)
return source_power
class Beamformer(dict):
"""A computed beamformer.
Notes
-----
.. versionadded:: 0.17
"""
def copy(self):
"""Copy the beamformer.
Returns
-------
beamformer : instance of Beamformer
A deep copy of the beamformer.
"""
return deepcopy(self)
def __repr__(self): # noqa: D105
n_verts = sum(len(v) for v in self["vertices"])
n_channels = len(self["ch_names"])
if self["subject"] is None:
subject = "unknown"
else:
subject = f'"{self["subject"]}"'
out = "<Beamformer | {}, subject {}, {} vert, {} ch".format(
self["kind"],
subject,
n_verts,
n_channels,
)
if self["pick_ori"] is not None:
out += f', {self["pick_ori"]} ori'
if self["weight_norm"] is not None:
out += f', {self["weight_norm"]} norm'
if self.get("inversion") is not None:
out += f', {self["inversion"]} inversion'
if "rank" in self:
out += f', rank {self["rank"]}'
out += ">"
return out
@verbose
def save(self, fname, overwrite=False, verbose=None):
"""Save the beamformer filter.
Parameters
----------
fname : path-like
The filename to use to write the HDF5 data.
Should end in ``'-lcmv.h5'`` or ``'-dics.h5'``.
%(overwrite)s
%(verbose)s
"""
_, write_hdf5 = _import_h5io_funcs()
ending = f'-{self["kind"].lower()}.h5'
check_fname(fname, self["kind"], (ending,))
csd_orig = None
try:
if "csd" in self:
csd_orig = self["csd"]
self["csd"] = self["csd"].__getstate__()
write_hdf5(fname, self, overwrite=overwrite, title="mnepython")
finally:
if csd_orig is not None:
self["csd"] = csd_orig
def read_beamformer(fname):
"""Read a beamformer filter.
Parameters
----------
fname : path-like
The filename of the HDF5 file.
Returns
-------
filter : instance of Beamformer
The beamformer filter.
"""
read_hdf5, _ = _import_h5io_funcs()
beamformer = read_hdf5(fname, title="mnepython")
if "csd" in beamformer:
beamformer["csd"] = CrossSpectralDensity(**beamformer["csd"])
# h5io seems to cast `bool` to `int` on round-trip, probably a bug
# we should fix at some point (if possible -- could be HDF5 limitation)
for key in ("normalize_fwd", "is_free_ori", "is_ssp"):
if key in beamformer:
beamformer[key] = bool(beamformer[key])
for key in ("data_cov", "noise_cov"):
if beamformer.get(key) is not None:
for pi, p in enumerate(beamformer[key]["projs"]):
p = Projection(**p)
p["active"] = bool(p["active"])
beamformer[key]["projs"][pi] = p
beamformer[key] = Covariance(
*[
beamformer[key].get(arg)
for arg in (
"data",
"names",
"bads",
"projs",
"nfree",
"eig",
"eigvec",
"method",
"loglik",
)
]
)
return Beamformer(beamformer)
def _proj_whiten_data(M, proj, filters):
if filters.get("is_ssp", True):
# check whether data and filter projs match
_check_proj_match(proj, filters)
if filters["whitener"] is None:
M = np.dot(filters["proj"], M)
if filters["whitener"] is not None:
M = np.dot(filters["whitener"], M)
return M

648
mne/beamformer/_dics.py Normal file
View File

@@ -0,0 +1,648 @@
"""Dynamic Imaging of Coherent Sources (DICS)."""
# Authors: The MNE-Python contributors.
# License: BSD-3-Clause
# Copyright the MNE-Python contributors.
import numpy as np
from .._fiff.pick import pick_channels, pick_info
from ..channels import equalize_channels
from ..forward import _subject_from_forward
from ..minimum_norm.inverse import _check_depth, _check_reference, combine_xyz
from ..rank import compute_rank
from ..source_estimate import _get_src_type, _make_stc
from ..time_frequency import EpochsTFR
from ..time_frequency.tfr import _check_tfr_complex
from ..utils import (
_check_channels_spatial_filter,
_check_one_ch_type,
_check_option,
_check_rank,
_validate_type,
logger,
verbose,
warn,
)
from ._compute_beamformer import (
Beamformer,
_check_src_type,
_compute_beamformer,
_compute_power,
_prepare_beamformer_input,
_proj_whiten_data,
)
@verbose
def make_dics(
info,
forward,
csd,
reg=0.05,
noise_csd=None,
label=None,
pick_ori=None,
rank=None,
weight_norm=None,
reduce_rank=False,
depth=1.0,
real_filter=True,
inversion="matrix",
verbose=None,
):
"""Compute a Dynamic Imaging of Coherent Sources (DICS) spatial filter.
This is a beamformer filter that can be used to estimate the source power
at a specific frequency range :footcite:`GrossEtAl2001`. It does this by
constructing a spatial filter for each source point.
The computation of these filters is very similar to those of the LCMV
beamformer (:func:`make_lcmv`), but instead of operating on a covariance
matrix, the CSD matrix is used. When applying these filters to a CSD matrix
(see :func:`apply_dics_csd`), the source power can be estimated for each
source point.
Parameters
----------
%(info_not_none)s
forward : instance of Forward
Forward operator.
csd : instance of CrossSpectralDensity
The data cross-spectral density (CSD) matrices. A source estimate is
performed for each frequency or frequency-bin defined in the CSD
object.
reg : float
The regularization to apply to the cross-spectral density before
computing the inverse.
noise_csd : instance of CrossSpectralDensity | None
Noise cross-spectral density (CSD) matrices. If provided, whitening
will be done. The noise CSDs need to have been computed for the same
frequencies as the data CSDs. Providing noise CSDs is mandatory if you
mix sensor types, e.g. gradiometers with magnetometers or EEG with
MEG.
.. versionadded:: 0.20
label : Label | None
Restricts the solution to a given label.
%(pick_ori_bf)s
%(rank_none)s
.. versionadded:: 0.17
%(weight_norm)s
Defaults to ``None``, in which case no normalization is performed.
%(reduce_rank)s
%(depth)s
real_filter : bool
If ``True``, take only the real part of the cross-spectral-density
matrices to compute real filters.
.. versionchanged:: 0.23
Version 0.23 an earlier used ``real_filter=False`` as the default,
as of version 0.24 ``True`` is the default.
%(inversion_bf)s
.. versionchanged:: 0.21
Default changed to ``'matrix'``.
%(verbose)s
Returns
-------
filters : instance of Beamformer
Dictionary containing filter weights from DICS beamformer.
Contains the following keys:
'kind' : str
The type of beamformer, in this case 'DICS'.
'weights' : ndarray, shape (n_frequencies, n_weights)
For each frequency, the filter weights of the beamformer.
'csd' : instance of CrossSpectralDensity
The data cross-spectral density matrices used to compute the
beamformer.
'ch_names' : list of str
Channels used to compute the beamformer.
'proj' : ndarray, shape (n_channels, n_channels)
Projections used to compute the beamformer.
'vertices' : list of ndarray
Vertices for which the filter weights were computed.
'n_sources' : int
Number of source location for which the filter weight were
computed.
'subject' : str
The subject ID.
'pick-ori' : None | 'max-power' | 'normal' | 'vector'
The orientation in which the beamformer filters were computed.
'inversion' : 'single' | 'matrix'
Whether the spatial filters were computed for each dipole
separately or jointly for all dipoles at each vertex using a
matrix inversion.
'weight_norm' : None | 'unit-noise-gain'
The normalization of the weights.
'src_type' : str
Type of source space.
'source_nn' : ndarray, shape (n_sources, 3)
For each source location, the surface normal.
'is_free_ori' : bool
Whether the filter was computed in a fixed direction
(pick_ori='max-power', pick_ori='normal') or not.
'whitener' : None | ndarray, shape (n_channels, n_channels)
Whitening matrix, provided if whitening was applied to the
covariance matrix and leadfield during computation of the
beamformer weights.
'max-power-ori' : ndarray, shape (n_sources, 3) | None
When pick_ori='max-power', this fields contains the estimated
direction of maximum power at each source location.
See Also
--------
apply_dics_csd
Notes
-----
The original reference is :footcite:`GrossEtAl2001`. See
:footcite:`vanVlietEtAl2018` for a tutorial style paper on the topic.
The DICS beamformer is very similar to the LCMV (:func:`make_lcmv`)
beamformer and many of the parameters are shared. However,
:func:`make_dics` and :func:`make_lcmv` currently have different defaults
for these parameters, which were settled on separately through extensive
practical use case testing (but not necessarily exhaustive parameter space
searching), and it remains to be seen how functionally interchangeable they
could be.
The default setting reproduce the DICS beamformer as described in
:footcite:`vanVlietEtAl2018`::
inversion='single', weight_norm=None, depth=1.
To use the :func:`make_lcmv` defaults, use::
inversion='matrix', weight_norm='unit-noise-gain-invariant', depth=None
For more information about ``real_filter``, see the
supplemental information from :footcite:`HippEtAl2011`.
References
----------
.. footbibliography::
""" # noqa: E501
rank = _check_rank(rank)
_check_option("pick_ori", pick_ori, [None, "vector", "normal", "max-power"])
_check_option("inversion", inversion, ["single", "matrix"])
_validate_type(weight_norm, (str, None), "weight_norm")
frequencies = [np.mean(freq_bin) for freq_bin in csd.frequencies]
n_freqs = len(frequencies)
_, _, allow_mismatch = _check_one_ch_type("dics", info, forward, csd, noise_csd)
# remove bads so that equalize_channels only keeps all good
info = pick_info(info, pick_channels(info["ch_names"], [], info["bads"]))
info, forward, csd = equalize_channels([info, forward, csd])
csd, noise_csd = _prepare_noise_csd(csd, noise_csd, real_filter)
depth = _check_depth(depth, "depth_sparse")
if inversion == "single":
depth["combine_xyz"] = False
(
is_free_ori,
info,
proj,
vertices,
G,
whitener,
nn,
orient_std,
) = _prepare_beamformer_input(
info,
forward,
label,
pick_ori,
noise_cov=noise_csd,
rank=rank,
pca=False,
**depth,
)
# Compute ranks
csd_int_rank = []
if not allow_mismatch:
noise_rank = compute_rank(noise_csd, info=info, rank=rank)
for i in range(len(frequencies)):
csd_rank = compute_rank(
csd.get_data(index=i, as_cov=True), info=info, rank=rank
)
if not allow_mismatch:
for key in csd_rank:
if key not in noise_rank or csd_rank[key] != noise_rank[key]:
raise ValueError(
f"{key} data rank ({csd_rank[key]}) did not match the noise "
f"rank ({noise_rank.get(key, None)})"
)
csd_int_rank.append(sum(csd_rank.values()))
del noise_csd
ch_names = list(info["ch_names"])
logger.info("Computing DICS spatial filters...")
Ws = []
max_oris = []
for i, freq in enumerate(frequencies):
if n_freqs > 1:
logger.info(
" computing DICS spatial filter at "
f"{round(freq, 2)} Hz ({i + 1}/{n_freqs})"
)
Cm = csd.get_data(index=i)
# XXX: Weird that real_filter happens *before* whitening, which could
# make things complex again...?
if real_filter:
Cm = Cm.real
# compute spatial filter
n_orient = 3 if is_free_ori else 1
W, max_power_ori = _compute_beamformer(
G,
Cm,
reg,
n_orient,
weight_norm,
pick_ori,
reduce_rank,
rank=csd_int_rank[i],
inversion=inversion,
nn=nn,
orient_std=orient_std,
whitener=whitener,
)
Ws.append(W)
max_oris.append(max_power_ori)
Ws = np.array(Ws)
if pick_ori == "max-power":
max_oris = np.array(max_oris)
else:
max_oris = None
src_type = _get_src_type(forward["src"], vertices)
subject = _subject_from_forward(forward)
is_free_ori = is_free_ori if pick_ori in [None, "vector"] else False
n_sources = np.sum([len(v) for v in vertices])
filters = Beamformer(
kind="DICS",
weights=Ws,
csd=csd,
ch_names=ch_names,
proj=proj,
vertices=vertices,
n_sources=n_sources,
subject=subject,
pick_ori=pick_ori,
inversion=inversion,
weight_norm=weight_norm,
src_type=src_type,
source_nn=forward["source_nn"].copy(),
is_free_ori=is_free_ori,
whitener=whitener,
max_power_ori=max_oris,
)
return filters
def _prepare_noise_csd(csd, noise_csd, real_filter):
if noise_csd is not None:
csd, noise_csd = equalize_channels([csd, noise_csd])
# Use the same noise CSD for all frequencies
if len(noise_csd.frequencies) > 1:
noise_csd = noise_csd.mean()
noise_csd = noise_csd.get_data(as_cov=True)
if real_filter:
noise_csd["data"] = noise_csd["data"].real
return csd, noise_csd
def _apply_dics(data, filters, info, tmin, tfr=False):
"""Apply DICS spatial filter to data for source reconstruction."""
if isinstance(data, np.ndarray) and data.ndim == (2 + tfr):
data = [data]
one_epoch = True
else:
one_epoch = False
Ws = filters["weights"]
one_freq = len(Ws) == 1
subject = filters["subject"]
# compatibility with 0.16, add src_type as None if not present:
filters, warn_text = _check_src_type(filters)
for i, M in enumerate(data):
if not one_epoch:
logger.info(f"Processing epoch : {i + 1}")
# Apply SSPs
if not tfr: # save computation, only compute once
M_w = _proj_whiten_data(M, info["projs"], filters)
stcs = []
for j, W in enumerate(Ws):
if tfr: # must compute for each frequency
M_w = _proj_whiten_data(M[:, j], info["projs"], filters)
# project to source space using beamformer weights
sol = np.dot(W, M_w)
if filters["is_free_ori"] and filters["pick_ori"] != "vector":
logger.info("combining the current components...")
sol = combine_xyz(sol)
tstep = 1.0 / info["sfreq"]
stcs.append(
_make_stc(
sol,
vertices=filters["vertices"],
src_type=filters["src_type"],
tmin=tmin,
tstep=tstep,
subject=subject,
vector=(filters["pick_ori"] == "vector"),
source_nn=filters["source_nn"],
warn_text=warn_text,
)
)
if one_freq:
yield stcs[0]
else:
yield stcs
logger.info("[done]")
@verbose
def apply_dics(evoked, filters, verbose=None):
"""Apply Dynamic Imaging of Coherent Sources (DICS) beamformer weights.
Apply Dynamic Imaging of Coherent Sources (DICS) beamformer weights
on evoked data.
.. warning:: The result of this function is meant as an intermediate step
for further processing (such as computing connectivity). If
you are interested in estimating source time courses, use an
LCMV beamformer (:func:`make_lcmv`, :func:`apply_lcmv`)
instead. If you are interested in estimating spectral power at
the source level, use :func:`apply_dics_csd`.
.. warning:: This implementation has not been heavily tested so please
report any issues or suggestions.
Parameters
----------
evoked : Evoked
Evoked data to apply the DICS beamformer weights to.
filters : instance of Beamformer
DICS spatial filter (beamformer weights)
Filter weights returned from :func:`make_dics`.
%(verbose)s
Returns
-------
stc : SourceEstimate | VolSourceEstimate | list
Source time courses. If the DICS beamformer has been computed for more
than one frequency, a list is returned containing for each frequency
the corresponding time courses.
See Also
--------
apply_dics_epochs
apply_dics_tfr_epochs
apply_dics_csd
""" # noqa: E501
_check_reference(evoked)
info = evoked.info
data = evoked.data
tmin = evoked.times[0]
sel = _check_channels_spatial_filter(evoked.ch_names, filters)
data = data[sel]
stc = _apply_dics(data=data, filters=filters, info=info, tmin=tmin)
return next(stc)
@verbose
def apply_dics_epochs(epochs, filters, return_generator=False, verbose=None):
"""Apply Dynamic Imaging of Coherent Sources (DICS) beamformer weights.
Apply Dynamic Imaging of Coherent Sources (DICS) beamformer weights
on single trial data.
.. warning:: The result of this function is meant as an intermediate step
for further processing (such as computing connectivity). If
you are interested in estimating source time courses, use an
LCMV beamformer (:func:`make_lcmv`, :func:`apply_lcmv`)
instead. If you are interested in estimating spectral power at
the source level, use :func:`apply_dics_csd`.
.. warning:: This implementation has not been heavily tested so please
report any issue or suggestions.
Parameters
----------
epochs : Epochs
Single trial epochs.
filters : instance of Beamformer
DICS spatial filter (beamformer weights)
Filter weights returned from :func:`make_dics`. The DICS filters must
have been computed for a single frequency only.
return_generator : bool
Return a generator object instead of a list. This allows iterating
over the stcs without having to keep them all in memory.
%(verbose)s
Returns
-------
stc: list | generator of (SourceEstimate | VolSourceEstimate)
The source estimates for all epochs.
See Also
--------
apply_dics
apply_dics_tfr_epochs
apply_dics_csd
"""
_check_reference(epochs)
if len(filters["weights"]) > 1:
raise ValueError(
"This function only works on DICS beamformer weights that have "
"been computed for a single frequency. When calling make_dics(), "
"make sure to use a CSD object with only a single frequency (or "
"frequency-bin) defined."
)
info = epochs.info
tmin = epochs.times[0]
sel = _check_channels_spatial_filter(epochs.ch_names, filters)
data = epochs.get_data(sel)
stcs = _apply_dics(data=data, filters=filters, info=info, tmin=tmin)
if not return_generator:
stcs = list(stcs)
return stcs
@verbose
def apply_dics_tfr_epochs(epochs_tfr, filters, return_generator=False, verbose=None):
"""Apply Dynamic Imaging of Coherent Sources (DICS) beamformer weights.
Apply Dynamic Imaging of Coherent Sources (DICS) beamformer weights
on single trial time-frequency data.
Parameters
----------
epochs_tfr : EpochsTFR
Single trial time-frequency epochs.
filters : instance of Beamformer
DICS spatial filter (beamformer weights)
Filter weights returned from :func:`make_dics`.
return_generator : bool
Return a generator object instead of a list. This allows iterating
over the stcs without having to keep them all in memory.
%(verbose)s
Returns
-------
stcs : list of list of (SourceEstimate | VectorSourceEstimate | VolSourceEstimate)
The source estimates for all epochs (outside list) and for
all frequencies (inside list).
See Also
--------
apply_dics
apply_dics_epochs
apply_dics_csd
""" # noqa E501
_validate_type(epochs_tfr, EpochsTFR)
_check_tfr_complex(epochs_tfr)
if filters["pick_ori"] == "vector":
warn(
"Using a vector solution to compute power will lead to "
"inaccurate directions (only in the first quadrent) "
"because power is a strictly positive (squared) metric. "
"Using singular value decomposition (SVD) to determine "
"the direction is not yet supported in MNE."
)
sel = _check_channels_spatial_filter(epochs_tfr.ch_names, filters)
data = epochs_tfr.data[:, sel, :, :]
stcs = _apply_dics(data, filters, epochs_tfr.info, epochs_tfr.tmin, tfr=True)
if not return_generator:
stcs = [[stc for stc in tfr_stcs] for tfr_stcs in stcs]
return stcs
@verbose
def apply_dics_csd(csd, filters, verbose=None):
"""Apply Dynamic Imaging of Coherent Sources (DICS) beamformer weights.
Apply a previously computed DICS beamformer to a cross-spectral density
(CSD) object to estimate source power in time and frequency windows
specified in the CSD object :footcite:`GrossEtAl2001`.
.. note:: Only power can computed from the cross-spectral density, not
complex phase-amplitude, so vector DICS filters will be
converted to scalar source estimates since power is strictly
positive and so 3D directions cannot be combined meaningfully
(the direction would be confined to the positive quadrant).
Parameters
----------
csd : instance of CrossSpectralDensity
The data cross-spectral density (CSD) matrices. A source estimate is
performed for each frequency or frequency-bin defined in the CSD
object.
filters : instance of Beamformer
DICS spatial filter (beamformer weights)
Filter weights returned from `make_dics`.
%(verbose)s
Returns
-------
stc : SourceEstimate
Source power with frequency instead of time.
frequencies : list of float
The frequencies for which the source power has been computed. If the
data CSD object defines frequency-bins instead of exact frequencies,
the mean of each bin is returned.
See Also
--------
apply_dics
apply_dics_epochs
apply_dics_tfr_epochs
References
----------
.. footbibliography::
""" # noqa: E501
ch_names = filters["ch_names"]
vertices = filters["vertices"]
n_orient = 3 if filters["is_free_ori"] else 1
subject = filters["subject"]
whitener = filters["whitener"]
n_sources = filters["n_sources"]
# If CSD is summed over multiple frequencies, take the average frequency
frequencies = [np.mean(dfreq) for dfreq in csd.frequencies]
n_freqs = len(frequencies)
source_power = np.zeros((n_sources, len(csd.frequencies)))
# Ensure the CSD is in the same order as the weights
csd_picks = [csd.ch_names.index(ch) for ch in ch_names]
logger.info("Computing DICS source power...")
for i, freq in enumerate(frequencies):
if n_freqs > 1:
logger.info(
" applying DICS spatial filter at "
f"{round(freq, 2)} Hz ({i + 1}/{n_freqs})"
)
Cm = csd.get_data(index=i)
Cm = Cm[csd_picks, :][:, csd_picks]
W = filters["weights"][i]
# Whiten the CSD
Cm = np.dot(whitener, np.dot(Cm, whitener.conj().T))
source_power[:, i] = _compute_power(Cm, W, n_orient)
logger.info("[done]")
# compatibility with 0.16, add src_type as None if not present:
filters, warn_text = _check_src_type(filters)
return (
_make_stc(
source_power,
vertices=vertices,
src_type=filters["src_type"],
tmin=0.0,
tstep=1.0,
subject=subject,
warn_text=warn_text,
),
frequencies,
)

503
mne/beamformer/_lcmv.py Normal file
View File

@@ -0,0 +1,503 @@
"""Compute Linearly constrained minimum variance (LCMV) beamformer."""
# Authors: The MNE-Python contributors.
# License: BSD-3-Clause
# Copyright the MNE-Python contributors.
import numpy as np
from .._fiff.meas_info import _simplify_info
from .._fiff.pick import pick_channels_cov, pick_info
from ..forward import _subject_from_forward
from ..minimum_norm.inverse import _check_depth, _check_reference, combine_xyz
from ..rank import compute_rank
from ..source_estimate import _get_src_type, _make_stc
from ..utils import (
_check_channels_spatial_filter,
_check_info_inv,
_check_one_ch_type,
logger,
verbose,
)
from ._compute_beamformer import (
Beamformer,
_check_src_type,
_compute_beamformer,
_compute_power,
_prepare_beamformer_input,
_proj_whiten_data,
)
@verbose
def make_lcmv(
info,
forward,
data_cov,
reg=0.05,
noise_cov=None,
label=None,
pick_ori=None,
rank="info",
weight_norm="unit-noise-gain-invariant",
reduce_rank=False,
depth=None,
inversion="matrix",
verbose=None,
):
"""Compute LCMV spatial filter.
Parameters
----------
%(info_not_none)s
Specifies the channels to include. Bad channels (in ``info['bads']``)
are not used.
forward : instance of Forward
Forward operator.
data_cov : instance of Covariance
The data covariance.
reg : float
The regularization for the whitened data covariance.
noise_cov : instance of Covariance
The noise covariance. If provided, whitening will be done. Providing a
noise covariance is mandatory if you mix sensor types, e.g.
gradiometers with magnetometers or EEG with MEG.
.. note::
If ``noise_cov`` is ``None`` and ``weight_norm='unit-noise-gain'``,
the unit noise is assumed to be 1 in SI units, e.g., 1 T for
magnetometers, 1 V for EEG, so resulting amplitudes will be tiny.
Consider using :func:`mne.make_ad_hoc_cov` to provide a
``noise_cov`` to set noise values that are more reasonable for
neural data or using ``weight_norm='nai'`` for weight-normalized
beamformer output that is scaled by a noise estimate.
label : instance of Label
Restricts the LCMV solution to a given label.
%(pick_ori_bf)s
- ``'vector'``
Keeps the currents for each direction separate
%(rank_info)s
%(weight_norm)s
Defaults to ``'unit-noise-gain-invariant'``.
%(reduce_rank)s
%(depth)s
.. versionadded:: 0.18
%(inversion_bf)s
.. versionadded:: 0.21
%(verbose)s
Returns
-------
filters : instance of Beamformer
Dictionary containing filter weights from LCMV beamformer.
Contains the following keys:
'kind' : str
The type of beamformer, in this case 'LCMV'.
'weights' : array
The filter weights of the beamformer.
'data_cov' : instance of Covariance
The data covariance matrix used to compute the beamformer.
'noise_cov' : instance of Covariance | None
The noise covariance matrix used to compute the beamformer.
'whitener' : None | ndarray, shape (n_channels, n_channels)
Whitening matrix, provided if whitening was applied to the
covariance matrix and leadfield during computation of the
beamformer weights.
'weight_norm' : str | None
Type of weight normalization used to compute the filter
weights.
'pick-ori' : None | 'max-power' | 'normal' | 'vector'
The orientation in which the beamformer filters were computed.
'ch_names' : list of str
Channels used to compute the beamformer.
'proj' : array
Projections used to compute the beamformer.
'is_ssp' : bool
If True, projections were applied prior to filter computation.
'vertices' : list
Vertices for which the filter weights were computed.
'is_free_ori' : bool
If True, the filter was computed with free source orientation.
'n_sources' : int
Number of source location for which the filter weight were
computed.
'src_type' : str
Type of source space.
'source_nn' : ndarray, shape (n_sources, 3)
For each source location, the surface normal.
'proj' : ndarray, shape (n_channels, n_channels)
Projections used to compute the beamformer.
'subject' : str
The subject ID.
'rank' : int
The rank of the data covariance matrix used to compute the
beamformer weights.
'max-power-ori' : ndarray, shape (n_sources, 3) | None
When pick_ori='max-power', this fields contains the estimated
direction of maximum power at each source location.
'inversion' : 'single' | 'matrix'
Whether the spatial filters were computed for each dipole
separately or jointly for all dipoles at each vertex using a
matrix inversion.
Notes
-----
The original reference is :footcite:`VanVeenEtAl1997`.
To obtain the Sekihara unit-noise-gain vector beamformer, you should use
``weight_norm='unit-noise-gain', pick_ori='vector'`` followed by
:meth:`vec_stc.project('pca', src) <mne.VectorSourceEstimate.project>`.
.. versionchanged:: 0.21
The computations were extensively reworked, and the default for
``weight_norm`` was set to ``'unit-noise-gain-invariant'``.
References
----------
.. footbibliography::
"""
# check number of sensor types present in the data and ensure a noise cov
info = _simplify_info(info, keep=("proc_history",))
noise_cov, _, allow_mismatch = _check_one_ch_type(
"lcmv", info, forward, data_cov, noise_cov
)
# XXX we need this extra picking step (can't just rely on minimum norm's
# because there can be a mismatch. Should probably add an extra arg to
# _prepare_beamformer_input at some point (later)
picks = _check_info_inv(info, forward, data_cov, noise_cov)
info = pick_info(info, picks)
data_rank = compute_rank(data_cov, rank=rank, info=info)
noise_rank = compute_rank(noise_cov, rank=rank, info=info)
for key in data_rank:
if (
key not in noise_rank or data_rank[key] != noise_rank[key]
) and not allow_mismatch:
raise ValueError(
f"{key} data rank ({data_rank[key]}) did not match the noise rank ("
f"{noise_rank.get(key, None)})"
)
del noise_rank
rank = data_rank
logger.info(f"Making LCMV beamformer with rank {rank}")
del data_rank
depth = _check_depth(depth, "depth_sparse")
if inversion == "single":
depth["combine_xyz"] = False
(
is_free_ori,
info,
proj,
vertno,
G,
whitener,
nn,
orient_std,
) = _prepare_beamformer_input(
info,
forward,
label,
pick_ori,
noise_cov=noise_cov,
rank=rank,
pca=False,
**depth,
)
ch_names = list(info["ch_names"])
data_cov = pick_channels_cov(data_cov, include=ch_names)
Cm = data_cov._get_square()
if "estimator" in data_cov:
del data_cov["estimator"]
rank_int = sum(rank.values())
del rank
# compute spatial filter
n_orient = 3 if is_free_ori else 1
W, max_power_ori = _compute_beamformer(
G,
Cm,
reg,
n_orient,
weight_norm,
pick_ori,
reduce_rank,
rank_int,
inversion=inversion,
nn=nn,
orient_std=orient_std,
whitener=whitener,
)
# get src type to store with filters for _make_stc
src_type = _get_src_type(forward["src"], vertno)
# get subject to store with filters
subject_from = _subject_from_forward(forward)
# Is the computed beamformer a scalar or vector beamformer?
is_free_ori = is_free_ori if pick_ori in [None, "vector"] else False
is_ssp = bool(info["projs"])
filters = Beamformer(
kind="LCMV",
weights=W,
data_cov=data_cov,
noise_cov=noise_cov,
whitener=whitener,
weight_norm=weight_norm,
pick_ori=pick_ori,
ch_names=ch_names,
proj=proj,
is_ssp=is_ssp,
vertices=vertno,
is_free_ori=is_free_ori,
n_sources=forward["nsource"],
src_type=src_type,
source_nn=forward["source_nn"].copy(),
subject=subject_from,
rank=rank_int,
max_power_ori=max_power_ori,
inversion=inversion,
)
return filters
def _apply_lcmv(data, filters, info, tmin):
"""Apply LCMV spatial filter to data for source reconstruction."""
if isinstance(data, np.ndarray) and data.ndim == 2:
data = [data]
return_single = True
else:
return_single = False
W = filters["weights"]
for i, M in enumerate(data):
if len(M) != len(filters["ch_names"]):
raise ValueError("data and picks must have the same length")
if not return_single:
logger.info(f"Processing epoch : {i + 1}")
M = _proj_whiten_data(M, info["projs"], filters)
# project to source space using beamformer weights
vector = False
if filters["is_free_ori"]:
sol = np.dot(W, M)
if filters["pick_ori"] == "vector":
vector = True
else:
logger.info("combining the current components...")
sol = combine_xyz(sol)
else:
# Linear inverse: do computation here or delayed
if M.shape[0] < W.shape[0] and filters["pick_ori"] != "max-power":
sol = (W, M)
else:
sol = np.dot(W, M)
tstep = 1.0 / info["sfreq"]
# compatibility with 0.16, add src_type as None if not present:
filters, warn_text = _check_src_type(filters)
yield _make_stc(
sol,
vertices=filters["vertices"],
tmin=tmin,
tstep=tstep,
subject=filters["subject"],
vector=vector,
source_nn=filters["source_nn"],
src_type=filters["src_type"],
warn_text=warn_text,
)
logger.info("[done]")
@verbose
def apply_lcmv(evoked, filters, *, verbose=None):
"""Apply Linearly Constrained Minimum Variance (LCMV) beamformer weights.
Apply Linearly Constrained Minimum Variance (LCMV) beamformer weights
on evoked data.
Parameters
----------
evoked : Evoked
Evoked data to invert.
filters : instance of Beamformer
LCMV spatial filter (beamformer weights).
Filter weights returned from :func:`make_lcmv`.
%(verbose)s
Returns
-------
stc : SourceEstimate | VolSourceEstimate | VectorSourceEstimate
Source time courses.
See Also
--------
make_lcmv, apply_lcmv_raw, apply_lcmv_epochs, apply_lcmv_cov
Notes
-----
.. versionadded:: 0.18
"""
_check_reference(evoked)
info = evoked.info
data = evoked.data
tmin = evoked.times[0]
sel = _check_channels_spatial_filter(evoked.ch_names, filters)
data = data[sel]
stc = _apply_lcmv(data=data, filters=filters, info=info, tmin=tmin)
return next(stc)
@verbose
def apply_lcmv_epochs(epochs, filters, *, return_generator=False, verbose=None):
"""Apply Linearly Constrained Minimum Variance (LCMV) beamformer weights.
Apply Linearly Constrained Minimum Variance (LCMV) beamformer weights
on single trial data.
Parameters
----------
epochs : Epochs
Single trial epochs.
filters : instance of Beamformer
LCMV spatial filter (beamformer weights)
Filter weights returned from :func:`make_lcmv`.
return_generator : bool
Return a generator object instead of a list. This allows iterating
over the stcs without having to keep them all in memory.
%(verbose)s
Returns
-------
stc: list | generator of (SourceEstimate | VolSourceEstimate)
The source estimates for all epochs.
See Also
--------
make_lcmv, apply_lcmv_raw, apply_lcmv, apply_lcmv_cov
"""
_check_reference(epochs)
info = epochs.info
tmin = epochs.times[0]
sel = _check_channels_spatial_filter(epochs.ch_names, filters)
data = epochs.get_data(sel)
stcs = _apply_lcmv(data=data, filters=filters, info=info, tmin=tmin)
if not return_generator:
stcs = [s for s in stcs]
return stcs
@verbose
def apply_lcmv_raw(raw, filters, start=None, stop=None, *, verbose=None):
"""Apply Linearly Constrained Minimum Variance (LCMV) beamformer weights.
Apply Linearly Constrained Minimum Variance (LCMV) beamformer weights
on raw data.
Parameters
----------
raw : mne.io.Raw
Raw data to invert.
filters : instance of Beamformer
LCMV spatial filter (beamformer weights).
Filter weights returned from :func:`make_lcmv`.
start : int
Index of first time sample (index not time is seconds).
stop : int
Index of first time sample not to include (index not time is seconds).
%(verbose)s
Returns
-------
stc : SourceEstimate | VolSourceEstimate
Source time courses.
See Also
--------
make_lcmv, apply_lcmv_epochs, apply_lcmv, apply_lcmv_cov
"""
_check_reference(raw)
info = raw.info
sel = _check_channels_spatial_filter(raw.ch_names, filters)
data, times = raw[sel, start:stop]
tmin = times[0]
stc = _apply_lcmv(data=data, filters=filters, info=info, tmin=tmin)
return next(stc)
@verbose
def apply_lcmv_cov(data_cov, filters, verbose=None):
"""Apply Linearly Constrained Minimum Variance (LCMV) beamformer weights.
Apply Linearly Constrained Minimum Variance (LCMV) beamformer weights
to a data covariance matrix to estimate source power.
Parameters
----------
data_cov : instance of Covariance
Data covariance matrix.
filters : instance of Beamformer
LCMV spatial filter (beamformer weights).
Filter weights returned from :func:`make_lcmv`.
%(verbose)s
Returns
-------
stc : SourceEstimate | VolSourceEstimate
Source power.
See Also
--------
make_lcmv, apply_lcmv, apply_lcmv_epochs, apply_lcmv_raw
"""
sel = _check_channels_spatial_filter(data_cov.ch_names, filters)
sel_names = [data_cov.ch_names[ii] for ii in sel]
data_cov = pick_channels_cov(data_cov, sel_names)
n_orient = filters["weights"].shape[0] // filters["n_sources"]
# Need to project and whiten along both dimensions
data = _proj_whiten_data(data_cov["data"].T, data_cov["projs"], filters)
data = _proj_whiten_data(data.T, data_cov["projs"], filters)
del data_cov
source_power = _compute_power(data, filters["weights"], n_orient)
# compatibility with 0.16, add src_type as None if not present:
filters, warn_text = _check_src_type(filters)
return _make_stc(
source_power,
vertices=filters["vertices"],
src_type=filters["src_type"],
tmin=0.0,
tstep=1.0,
subject=filters["subject"],
source_nn=filters["source_nn"],
warn_text=warn_text,
)

View File

@@ -0,0 +1,315 @@
"""Compute a Recursively Applied and Projected MUltiple Signal Classification (RAP-MUSIC).""" # noqa
# Authors: The MNE-Python contributors.
# License: BSD-3-Clause
# Copyright the MNE-Python contributors.
import numpy as np
from scipy import linalg
from .._fiff.pick import pick_channels_forward, pick_info
from ..fixes import _safe_svd
from ..forward import convert_forward_solution, is_fixed_orient
from ..inverse_sparse.mxne_inverse import _make_dipoles_sparse
from ..minimum_norm.inverse import _log_exp_var
from ..utils import _check_info_inv, fill_doc, logger, verbose
from ._compute_beamformer import _prepare_beamformer_input
@fill_doc
def _apply_rap_music(
data, info, times, forward, noise_cov, n_dipoles=2, picks=None, use_trap=False
):
"""RAP-MUSIC or TRAP-MUSIC for evoked data.
Parameters
----------
data : array, shape (n_channels, n_times)
Evoked data.
%(info_not_none)s
times : array
Times.
forward : instance of Forward
Forward operator.
noise_cov : instance of Covariance
The noise covariance.
n_dipoles : int
The number of dipoles to estimate. The default value is 2.
picks : list of int
Caller ensures this is a list of int.
use_trap : bool
Use the TRAP-MUSIC variant if True (default False).
Returns
-------
dipoles : list of instances of Dipole
The dipole fits.
explained_data : array | None
Data explained by the dipoles using a least square fitting with the
selected active dipoles and their estimated orientation.
"""
info = pick_info(info, picks)
del picks
# things are much simpler if we avoid surface orientation
align = forward["source_nn"].copy()
if forward["surf_ori"] and not is_fixed_orient(forward):
forward = convert_forward_solution(forward, surf_ori=False)
is_free_ori, info, _, _, G, whitener, _, _ = _prepare_beamformer_input(
info, forward, noise_cov=noise_cov, rank=None
)
forward = pick_channels_forward(forward, info["ch_names"], ordered=True)
del info
# whiten the data (leadfield already whitened)
M = np.dot(whitener, data)
del data
_, eig_vectors = linalg.eigh(np.dot(M, M.T))
phi_sig = eig_vectors[:, -n_dipoles:]
n_orient = 3 if is_free_ori else 1
G.shape = (G.shape[0], -1, n_orient)
gain = forward["sol"]["data"].copy()
gain.shape = G.shape
n_channels = G.shape[0]
A = np.empty((n_channels, n_dipoles))
gain_dip = np.empty((n_channels, n_dipoles))
oris = np.empty((n_dipoles, 3))
poss = np.empty((n_dipoles, 3))
G_proj = G.copy()
phi_sig_proj = phi_sig.copy()
idxs = list()
for k in range(n_dipoles):
subcorr_max = -1.0
source_idx, source_ori, source_pos = 0, [0, 0, 0], [0, 0, 0]
for i_source in range(G.shape[1]):
Gk = G_proj[:, i_source]
subcorr, ori = _compute_subcorr(Gk, phi_sig_proj)
if subcorr > subcorr_max:
subcorr_max = subcorr
source_idx = i_source
source_ori = ori
source_pos = forward["source_rr"][i_source]
if n_orient == 3 and align is not None:
surf_normal = forward["source_nn"][3 * i_source + 2]
# make sure ori is aligned to the surface orientation
source_ori *= np.sign(source_ori @ surf_normal) or 1.0
if n_orient == 1:
source_ori = forward["source_nn"][i_source]
idxs.append(source_idx)
if n_orient == 3:
Ak = np.dot(G[:, source_idx], source_ori)
else:
Ak = G[:, source_idx, 0]
A[:, k] = Ak
oris[k] = source_ori
poss[k] = source_pos
logger.info(f"source {k + 1} found: p = {source_idx}")
if n_orient == 3:
logger.info("ori = {} {} {}".format(*tuple(oris[k])))
projection = _compute_proj(A[:, : k + 1])
G_proj = np.einsum("ab,bso->aso", projection, G)
phi_sig_proj = np.dot(projection, phi_sig)
if use_trap:
phi_sig_proj = phi_sig_proj[:, -(n_dipoles - k) :]
del G, G_proj
sol = linalg.lstsq(A, M)[0]
if n_orient == 3:
X = sol[:, np.newaxis] * oris[:, :, np.newaxis]
X.shape = (-1, len(times))
else:
X = sol
gain_active = gain[:, idxs]
if n_orient == 3:
gain_dip = (oris * gain_active).sum(-1)
idxs = np.array(idxs)
active_set = np.array([[3 * idxs, 3 * idxs + 1, 3 * idxs + 2]]).T.ravel()
else:
gain_dip = gain_active[:, :, 0]
active_set = idxs
gain_active = whitener @ gain_active.reshape(gain.shape[0], -1)
assert gain_active.shape == (n_channels, X.shape[0])
explained_data = gain_dip @ sol
M_estimate = whitener @ explained_data
_log_exp_var(M, M_estimate)
tstep = np.median(np.diff(times)) if len(times) > 1 else 1.0
dipoles = _make_dipoles_sparse(
X, active_set, forward, times[0], tstep, M, gain_active, active_is_idx=True
)
for dipole, ori in zip(dipoles, oris):
signs = np.sign((dipole.ori * ori).sum(-1, keepdims=True))
dipole.ori *= signs
dipole.amplitude *= signs[:, 0]
logger.info("[done]")
return dipoles, explained_data
def _compute_subcorr(G, phi_sig):
"""Compute the subspace correlation."""
Ug, Sg, Vg = _safe_svd(G, full_matrices=False)
# Now we look at the actual rank of the forward fields
# in G and handle the fact that it might be rank defficient
# eg. when using MEG and a sphere model for which the
# radial component will be truly 0.
rank = np.sum(Sg > (Sg[0] * 1e-6))
if rank == 0:
return 0, np.zeros(len(G))
rank = max(rank, 2) # rank cannot be 1
Ug, Sg, Vg = Ug[:, :rank], Sg[:rank], Vg[:rank]
tmp = np.dot(Ug.T.conjugate(), phi_sig)
Uc, Sc, _ = _safe_svd(tmp, full_matrices=False)
X = np.dot(Vg.T / Sg[None, :], Uc[:, 0]) # subcorr
return Sc[0], X / np.linalg.norm(X)
def _compute_proj(A):
"""Compute the orthogonal projection operation for a manifold vector A."""
U, _, _ = _safe_svd(A, full_matrices=False)
return np.identity(A.shape[0]) - np.dot(U, U.T.conjugate())
def _rap_music(evoked, forward, noise_cov, n_dipoles, return_residual, use_trap):
"""RAP-/TRAP-MUSIC implementation."""
info = evoked.info
data = evoked.data
times = evoked.times
picks = _check_info_inv(info, forward, data_cov=None, noise_cov=noise_cov)
data = data[picks]
dipoles, explained_data = _apply_rap_music(
data, info, times, forward, noise_cov, n_dipoles, picks, use_trap
)
if return_residual:
residual = evoked.copy().pick([info["ch_names"][p] for p in picks])
residual.data -= explained_data
active_projs = [p for p in residual.info["projs"] if p["active"]]
for p in active_projs:
p["active"] = False
residual.add_proj(active_projs, remove_existing=True)
residual.apply_proj()
return dipoles, residual
else:
return dipoles
@verbose
def rap_music(
evoked,
forward,
noise_cov,
n_dipoles=5,
return_residual=False,
*,
verbose=None,
):
"""RAP-MUSIC source localization method.
Compute Recursively Applied and Projected MUltiple SIgnal Classification
(RAP-MUSIC) :footcite:`MosherLeahy1999,MosherLeahy1996` on evoked data.
.. note:: The goodness of fit (GOF) of all the returned dipoles is the
same and corresponds to the GOF of the full set of dipoles.
Parameters
----------
evoked : instance of Evoked
Evoked data to localize.
forward : instance of Forward
Forward operator.
noise_cov : instance of Covariance
The noise covariance.
n_dipoles : int
The number of dipoles to look for. The default value is 5.
return_residual : bool
If True, the residual is returned as an Evoked instance.
%(verbose)s
Returns
-------
dipoles : list of instance of Dipole
The dipole fits.
residual : instance of Evoked
The residual a.k.a. data not explained by the dipoles.
Only returned if return_residual is True.
See Also
--------
mne.fit_dipole
mne.beamformer.trap_music
Notes
-----
.. versionadded:: 0.9.0
References
----------
.. footbibliography::
"""
return _rap_music(evoked, forward, noise_cov, n_dipoles, return_residual, False)
@verbose
def trap_music(
evoked,
forward,
noise_cov,
n_dipoles=5,
return_residual=False,
*,
verbose=None,
):
"""TRAP-MUSIC source localization method.
Compute Truncated Recursively Applied and Projected MUltiple SIgnal Classification
(TRAP-MUSIC) :footcite:`Makela2018` on evoked data.
.. note:: The goodness of fit (GOF) of all the returned dipoles is the
same and corresponds to the GOF of the full set of dipoles.
Parameters
----------
evoked : instance of Evoked
Evoked data to localize.
forward : instance of Forward
Forward operator.
noise_cov : instance of Covariance
The noise covariance.
n_dipoles : int
The number of dipoles to look for. The default value is 5.
return_residual : bool
If True, the residual is returned as an Evoked instance.
%(verbose)s
Returns
-------
dipoles : list of instance of Dipole
The dipole fits.
residual : instance of Evoked
The residual a.k.a. data not explained by the dipoles.
Only returned if return_residual is True.
See Also
--------
mne.fit_dipole
mne.beamformer.rap_music
Notes
-----
.. versionadded:: 1.4
References
----------
.. footbibliography::
"""
return _rap_music(evoked, forward, noise_cov, n_dipoles, return_residual, True)

View File

@@ -0,0 +1,86 @@
"""Compute resolution matrix for beamformers."""
# Authors: The MNE-Python contributors.
# License: BSD-3-Clause
# Copyright the MNE-Python contributors.
import numpy as np
from .._fiff.pick import pick_channels, pick_channels_forward, pick_info
from ..evoked import EvokedArray
from ..utils import fill_doc, logger
from ._lcmv import apply_lcmv
@fill_doc
def make_lcmv_resolution_matrix(filters, forward, info):
"""Compute resolution matrix for LCMV beamformer.
Parameters
----------
filters : instance of Beamformer
Dictionary containing filter weights from LCMV beamformer
(see mne.beamformer.make_lcmv).
forward : instance of Forward
Forward Solution with leadfield matrix.
%(info_not_none)s Used to compute LCMV filters.
Returns
-------
resmat : array, shape (n_dipoles_lcmv, n_dipoles_fwd)
Resolution matrix (filter matrix multiplied to leadfield from
forward solution). Numbers of rows (n_dipoles_lcmv) and columns
(n_dipoles_fwd) may differ by a factor depending on orientation
constraints of filter and forward solution, respectively (e.g. factor 3
for free dipole orientation versus factor 1 for scalar beamformers).
"""
# don't include bad channels from noise covariance matrix
bads_filt = filters["noise_cov"]["bads"]
ch_names = filters["noise_cov"]["names"]
# good channels
ch_names = [c for c in ch_names if (c not in bads_filt)]
# adjust channels in forward solution
forward = pick_channels_forward(forward, ch_names, ordered=True)
# get leadfield matrix from forward solution
leadfield = forward["sol"]["data"]
# get the filter weights for beamformer as matrix
filtmat = _get_matrix_from_lcmv(filters, forward, info)
# compute resolution matrix
resmat = filtmat.dot(leadfield)
logger.info(f"Dimensions of LCMV resolution matrix: {resmat.shape}.")
return resmat
def _get_matrix_from_lcmv(filters, forward, info, verbose=None):
"""Get inverse matrix for LCMV beamformer.
Returns
-------
invmat : array, shape (n_dipoles, n_channels)
Inverse matrix associated with LCMV beamformer filters.
"""
# number of channels for identity matrix
info = pick_info(info, pick_channels(info["ch_names"], filters["ch_names"]))
n_chs = len(info["ch_names"])
# create identity matrix as input for inverse operator
# set elements to zero for non-selected channels
id_mat = np.eye(n_chs)
# convert identity matrix to evoked data type (pretending it's an epochs
evo_ident = EvokedArray(id_mat, info=info, tmin=0.0)
# apply beamformer to identity matrix
stc_lcmv = apply_lcmv(evo_ident, filters, verbose=verbose)
# turn source estimate into numpsy array
invmat = stc_lcmv.data
return invmat