Skip to content

Commit

Permalink
Let users run find_bads_muscle also when no sensor positions are av…
Browse files Browse the repository at this point in the history
…ailable (mne-tools#12862)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Clemens Brunner <[email protected]>
Co-authored-by: Eric Larson <[email protected]>
  • Loading branch information
4 people authored Sep 20, 2024
1 parent a218f96 commit b9cdca8
Show file tree
Hide file tree
Showing 3 changed files with 50 additions and 9 deletions.
1 change: 1 addition & 0 deletions doc/changes/devel/12862.other.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
:meth:`mne.preprocessing.ICA.find_bads_muscle` can now be run when passing an ``inst`` without sensor positions. However, it will just use the first of three criteria (slope) to find muscle-related ICA components, by `Stefan Appelhoff`_.
51 changes: 42 additions & 9 deletions mne/preprocessing/ica.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
_PCA,
Bunch,
_check_all_same_channel_names,
_check_ch_locs,
_check_compensation_grade,
_check_fname,
_check_on_missing,
Expand Down Expand Up @@ -1955,6 +1956,9 @@ def find_bads_muscle(
has been modified to 45 Hz as a default based on the criteria being
more accurate in practice.
If ``inst`` is supplied without sensor positions, only the first criterion
(slope) is applied.
Parameters
----------
inst : instance of Raw, Epochs or Evoked
Expand Down Expand Up @@ -1992,6 +1996,8 @@ def find_bads_muscle(
"""
_validate_type(threshold, "numeric", "threshold")

slope_score, focus_score, smoothness_score = None, None, None

sources = self.get_sources(inst, start=start, stop=stop)
components = self.get_components()

Expand All @@ -2002,11 +2008,32 @@ def find_bads_muscle(
psds = psds.mean(axis=0)
slopes = np.polyfit(np.log10(freqs), np.log10(psds).T, 1)[0]

# typical muscle slope is ~0.15, non-muscle components negative
# so logistic with shift -0.5 and slope 0.25 so -0.5 -> 0.5 and 0->1
slope_score = expit((slopes + 0.5) / 0.25)

# Need sensor positions for the criteria below, so return with only one score
# if no positions available
picks = _picks_to_idx(
inst.info, self.ch_names, "all", exclude=(), allow_empty=False
)
if not _check_ch_locs(inst.info, picks=picks):
warn(
"No sensor positions found. Scores for bad muscle components are only "
"based on the 'slope' criterion."
)
scores = slope_score
self.labels_["muscle"] = [
idx for idx, score in enumerate(scores) if score > threshold
]
return self.labels_["muscle"], scores

# compute metric #2: distance from the vertex of focus
components_norm = abs(components) / np.max(abs(components), axis=0)
# we need to retrieve the position from the channels that were used to
# fit the ICA. N.B: picks in _find_topomap_coords includes bad channels
# even if they are not provided explicitly.

pos = _find_topomap_coords(
inst.info, picks=self.ch_names, sphere=sphere, ignore_overlap=True
)
Expand All @@ -2016,6 +2043,10 @@ def find_bads_muscle(
dists /= dists.max()
focus_dists = np.dot(dists, components_norm)

# focus distance is ~65% of max electrode distance with 10% slope
# (assumes typical head size)
focus_score = expit((focus_dists - 0.65) / 0.1)

# compute metric #3: smoothness
smoothnesses = np.zeros((components.shape[1],))
dists = distance.squareform(distance.pdist(pos))
Expand All @@ -2025,20 +2056,22 @@ def find_bads_muscle(
comp_dists /= comp_dists.max()
smoothnesses[idx] = np.multiply(dists, comp_dists).sum()

# typical muscle slope is ~0.15, non-muscle components negative
# so logistic with shift -0.5 and slope 0.25 so -0.5 -> 0.5 and 0->1
slope_score = expit((slopes + 0.5) / 0.25)
# focus distance is ~65% of max electrode distance with 10% slope
# (assumes typical head size)
focus_score = expit((focus_dists - 0.65) / 0.1)
# smoothnessness is around 150 for muscle and 450 otherwise
# so use reversed logistic centered at 300 with 100 slope
smoothness_score = 1 - expit((smoothnesses - 300) / 100)
# multiply so that all three components must be present
scores = slope_score * focus_score * smoothness_score

# multiply all criteria that are present
scores = [
score
for score in [slope_score, focus_score, smoothness_score]
if score is not None
]
n_criteria = len(scores)
scores = np.prod(np.array(scores), axis=0)

# scale the threshold by the use of three metrics
self.labels_["muscle"] = [
idx for idx, score in enumerate(scores) if score > threshold**3
idx for idx, score in enumerate(scores) if score > threshold**n_criteria
]
return self.labels_["muscle"], scores

Expand Down
7 changes: 7 additions & 0 deletions mne/preprocessing/tests/test_ica.py
Original file line number Diff line number Diff line change
Expand Up @@ -1520,6 +1520,13 @@ def test_ica_labels():
ica.find_bads_muscle(raw)
assert "muscle" in ica.labels_

# Try without sensor locations
raw.set_montage(None)
with pytest.warns(RuntimeWarning, match="based on the 'slope' criterion"):
labels, scores = ica.find_bads_muscle(raw, threshold=0.35)
assert "muscle" in ica.labels_
assert labels == [3]


@testing.requires_testing_data
@pytest.mark.parametrize(
Expand Down

0 comments on commit b9cdca8

Please sign in to comment.