Skip to content
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

MRG: Enforce specification of all annotation descriptions in event_id if event_id is passed to write_raw_bids() #1086

Merged
merged 6 commits into from Oct 21, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 5 additions & 2 deletions doc/whats_new.rst
Expand Up @@ -28,7 +28,9 @@ The following authors contributed for the first time. Thank you so much! 🤩

The following authors had contributed before. Thank you for sticking around! 🤘

* ...
* `Alexandre Gramfort`_
* `Eric Larson`_
* `Richard Höchenberger`_

Detailed list of changes
~~~~~~~~~~~~~~~~~~~~~~~~
Expand All @@ -38,6 +40,7 @@ Detailed list of changes

- Speed up :func:`mne_bids.read_raw_bids` when lots of events are present by `Alexandre Gramfort`_ (:gh:`1079`)
- Add the option ``return_candidates`` to :meth:`mne_bids.BIDSPath.find_empty_room` by `Eric Larson`_ (:gh:`1083`)
- When writing data via :func:`~mne_bids.write_raw_bids`, it is now possible to specify a custom mapping of :class:`mne.Annotations` descriptions to event codes via the ``event_id`` parameter. Previously, passing this parameter would always require to also pass ``events``, and using a custom event code mapping for annotations was impossible, by `Richard Höchenberger`_ (:gh:`1084`)

🧐 API and behavior changes
^^^^^^^^^^^^^^^^^^^^^^^^^^^
Expand All @@ -52,7 +55,7 @@ Detailed list of changes
🪲 Bug fixes
^^^^^^^^^^^^

- ...
- When writing data containing :class:`mne.Annotations` **and** passing events to :func:`~mne_bids.write_raw_bids`, previously, annotations whose description did not appear in ``event_id`` were silently dropped. We now raise an exception and request users to specify mappings between descriptions and event codes in this case. It is still possible to omit ``event_id`` if no ``events`` are passed, by `Richard Höchenberger`_ (:gh:`1084`)


:doc:`Find out what was new in previous releases <whats_new_previous_releases>`
Expand Down
26 changes: 22 additions & 4 deletions mne_bids/read.py
Expand Up @@ -124,8 +124,29 @@ def _read_events(events, event_id, raw, bids_path=None):
else:
events = read_events(events).astype(int)

if raw.annotations:
if event_id is None:
logger.info(
'The provided raw data contains annotations, but you did not '
'pass an "event_id" mapping from annotation descriptions to '
'event codes. We will generate arbitrary event codes. '
'To specify custom event codes, please pass "event_id".'
)
else:
desc_without_id = sorted(
set(raw.annotations.description) - set(event_id.keys())
)
if desc_without_id:
raise ValueError(
f'The provided raw data contains annotations, but '
f'"event_id" does not contain entries for all annotation '
f'descriptions. The following entries are missing: '
f'{", ".join(desc_without_id)}'
)

# If we have events, convert them to Annotations so they can be easily
# merged with existing Annotations.
if events.size > 0:
# Only keep events for which we have an ID <> description mapping.
ids_without_desc = set(events[:, 2]) - set(event_id.values())
if ids_without_desc:
raise ValueError(
Expand All @@ -134,9 +155,6 @@ def _read_events(events, event_id, raw, bids_path=None):
f'Please add them to the event_id dictionary, or drop them '
f'from the events array.'
)
del ids_without_desc
mask = [e in list(event_id.values()) for e in events[:, 2]]
events = events[mask]

# Append events to raw.annotations. All event onsets are relative to
# measurement beginning.
Expand Down
84 changes: 76 additions & 8 deletions mne_bids/tests/test_write.py
Expand Up @@ -1272,17 +1272,10 @@ def test_eegieeg(dir_name, fname, reader, _bids_validate, tmp_path):
match='Encountered data in "double" format'):
bids_output_path = write_raw_bids(**kwargs)

event_id = {'Auditory/Left': 1, 'Auditory/Right': 2, 'Visual/Left': 3,
'Visual/Right': 4, 'Smiley': 5, 'Button': 32}

with pytest.raises(ValueError,
match='You passed events, but no event_id '):
write_raw_bids(raw, bids_path, events=events)

with pytest.raises(ValueError,
match='You passed event_id, but no events'):
write_raw_bids(raw, bids_path, event_id=event_id)

# check events.tsv is written
events_tsv_fname = bids_output_path.copy().update(suffix='events',
extension='.tsv')
Expand Down Expand Up @@ -2704,14 +2697,89 @@ def test_annotations(_bids_validate, bad_segments, tmp_path):
_bids_validate(bids_root)


@pytest.mark.parametrize(
'write_events', [True, False] # whether to pass "events" to write_raw_bids
)
@pytest.mark.filterwarnings(warning_str['channel_unit_changed'])
@testing.requires_testing_data
def test_annotations_and_events(_bids_validate, tmp_path, write_events):
"""Test combined writing of Annotations and events."""
bids_root = tmp_path / 'bids'
bids_path = _bids_path.copy().update(root=bids_root, datatype='meg')
raw_fname = data_path / 'MEG' / 'sample' / 'sample_audvis_trunc_raw.fif'
events_fname = (
data_path / 'MEG' / 'sample' / 'sample_audvis_trunc_raw-eve.fif'
)
events_tsv_fname = bids_path.copy().update(
suffix='events',
extension='.tsv',
)

events = mne.read_events(events_fname)
events = events[events[:, 2] != 0] # drop unknown "0" events
event_id = {'Auditory/Left': 1, 'Auditory/Right': 2, 'Visual/Left': 3,
'Visual/Right': 4, 'Smiley': 5, 'Button': 32}
raw = _read_raw_fif(raw_fname)
annotations = mne.Annotations(
# Try to avoid rounding errors.
onset=(
1 / raw.info['sfreq'] * 600,
1 / raw.info['sfreq'] * 600, # intentional
1 / raw.info['sfreq'] * 3000
),
duration=(
1 / raw.info['sfreq'],
1 / raw.info['sfreq'],
1 / raw.info['sfreq'] * 200
),
description=('BAD_segment', 'EDGE_segment', 'custom'),
)
raw.set_annotations(annotations)

# Write annotations while passing event_id
# Should raise since annotations descriptions are missing from event_id
with pytest.raises(ValueError, match='The following entries are missing'):
write_raw_bids(
raw,
bids_path=bids_path,
event_id=event_id,
events=events if write_events else None,
)

# Passing a complete mapping should work
event_id_with_annots = event_id.copy()
event_id_with_annots.update({
'BAD_segment': 9999,
'EDGE_segment': 10000,
'custom': 2000
})
write_raw_bids(
raw,
bids_path=bids_path,
event_id=event_id_with_annots,
events=events if write_events else None,
)
_bids_validate(bids_root)

# Ensure all events + annotations were written
events_tsv = _from_tsv(events_tsv_fname)

if write_events:
n_events_expected = len(events) + len(raw.annotations)
else:
n_events_expected = len(raw.annotations)

assert len(events_tsv['trial_type']) == n_events_expected


@pytest.mark.parametrize(
'drop_undescribed_events',
[True, False]
)
@pytest.mark.filterwarnings(warning_str['channel_unit_changed'])
@testing.requires_testing_data
def test_undescribed_events(_bids_validate, drop_undescribed_events, tmp_path):
"""Test we're behaving correctly if event descriptions are missing."""
"""Test we're raising if event descriptions are missing."""
bids_root = tmp_path / 'bids1'
bids_path = _bids_path.copy().update(root=bids_root, datatype='meg')
raw_fname = op.join(data_path, 'MEG', 'sample',
Expand Down
19 changes: 10 additions & 9 deletions mne_bids/write.py
Expand Up @@ -1337,14 +1337,16 @@ def write_raw_bids(
If an array, the MNE events array (shape: ``(n_events, 3)``).
If a path or an array and ``raw.annotations`` exist, the union of
``events`` and ``raw.annotations`` will be written.
Corresponding descriptions for all event codes (listed in the third
Mappings from event names to event codes (listed in the third
column of the MNE events array) must be specified via the ``event_id``
parameter; otherwise, an exception is raised.
parameter; otherwise, an exception is raised. If
:class:`~mne.Annotations` are present, their descriptions must be
included in ``event_id`` as well.
If ``None``, events will only be inferred from the raw object's
:class:`~mne.Annotations`.

.. note::
If ``not None``, writes the union of ``events`` and
If specified, writes the union of ``events`` and
``raw.annotations``. If you wish to **only** write
``raw.annotations``, pass ``events=None``. If you want to
**exclude** the events in ``raw.annotations`` from being written,
Expand All @@ -1359,7 +1361,10 @@ def write_raw_bids(
``events``. The descriptions will be written to the ``trial_type``
column in ``*_events.tsv``. The dictionary keys correspond to the event
description,s and the values to the event codes. You must specify a
description for all event codes appearing in ``events``.
description for all event codes appearing in ``events``. If your data
contains :class:`~mne.Annotations`, you can use this parameter to
assign event codes to each unique annotation description (mapping from
description to event code).
anonymize : dict | None
If `None` (default), no anonymization is performed.
If a dictionary, data will be anonymized depending on the dictionary
Expand Down Expand Up @@ -1575,11 +1580,7 @@ def write_raw_bids(

if events is not None and event_id is None:
raise ValueError('You passed events, but no event_id '
'dictionary. You need to pass both, or neither.')

if event_id is not None and events is None:
raise ValueError('You passed event_id, but no events. '
'You need to pass both, or neither.')
'dictionary.')

_validate_type(item=empty_room, item_name='empty_room',
types=(mne.io.BaseRaw, BIDSPath, None))
Expand Down