Repository: nilearn/nilearn

Issue:
handling of simultaneous events by plot_event
Other "problems" with `plot_event` that I am encountering while working on #3884.

1.  if 2 events have different trial_type but same onset and duration, then only the last one plotted is visible as it is overlayed over the other one

2. design matrix creation will sum the amplitude of 2 "duplicated" events (same trial type, onset, duration), however this won't be apparent when just using the plot_event function.

```python
import matplotlib.pyplot as plt
import numpy as np

from nilearn.glm.first_level import make_first_level_design_matrix
from nilearn.glm.tests._utils import duplicate_events_paradigm
from nilearn.plotting import plot_design_matrix, plot_event
from nilearn.plotting.matrix_plotting import plot_event

events = duplicate_events_paradigm()
events.drop(columns=["modulation"], inplace=True)
print(events)

plot_event(events)
plt.show()

fig, (ax1, ax2) = plt.subplots(figsize=(10, 6), nrows=1, ncols=2)

tr = 1.0
n_scans = 80
frame_times = np.arange(n_scans) * tr

X1 = make_first_level_design_matrix(
    frame_times,
    events,
    drift_model="polynomial",
    drift_order=3,
    hrf_model="spm",
)

plot_design_matrix(X1, ax=ax1)
ax1.set_title("design matrix with duplicated events", fontsize=12)

plt.plot(X1["c0"])
plt.plot(X1["c1"])
ax2.set_title("predicted time course of 'c0' and 'c1'", fontsize=12)

plt.show()
```

```
  trial_type  onset  duration
0         c0     10       1.0
1         c0     30       1.0
2         c0     70       1.0
3         c0     70       1.0  < -- duplicated events
4         c1     10       1.0  < -- "hides" similar events from c0
5         c1     30       1.0  < -- "hides" similar events from c0
```

![events](https://github.com/nilearn/nilearn/assets/6961185/f928b13a-c283-409f-af21-0c64b0bf87ac)

![Figure_1](https://github.com/nilearn/nilearn/assets/6961185/ed8516eb-9782-4555-badc-53014aa1d142)

(1) Could be solved but may require plotting events on several "rows" (one per condition).

(2) May be the intended behavior, but if not this could probably be easily solved by running `check_events` on the input of `plot_event` (which may also help with some of the input validation that is missing from this function in anycase: for example, no checking that the required columns are there, do not have NaNs...)

Notes:

- I do find it confusing that a function called `check_events` takes care of summing the modulation of the events duplicates: this behavior should probably extracted in a separate function

- although a warning is sent when events duplicates are detected, it may be more user friendly to have the possibility to drop duplicated events rather than sum their modulation





Discussion:
> (1) Could be solved but may require plotting events on several "rows" (one per condition).

Indeed. But is this really needed ? I guess that people avoid having simultaneous events in general ? Just worried about yagni 

> (2) May be the intended behavior, but if not this could probably be easily solved by running `check_events` on the input of `plot_event` (which may also help with some of the input validation that is missing from this function in anycase: for example, no checking that the required columns are there, do not have NaNs...)

Could be a good idea indeed.
> * I do find it confusing that a function called `check_events` takes care of summing the modulation of the events duplicates: this behavior should probably extracted in a separate function

Yes
> * although a warning is sent when events duplicates are detected, it may be more user friendly to have the possibility to drop duplicated events rather than sum their modulation

If this needed(?)
> Indeed. But is this really needed ? I guess that people avoid having simultaneous events in general ? Just worried about yagni

Agreed that this falls in the overkill category and that most events file in fmri are (unfortunately) not as rich as those from EEG.
Still think that throwing a warning may be a good thing.

> > running `check_events` on the input of `plot_event`
>
> Could be a good idea indeed.

Will PR this after #3884 to keep PR small


> summing the modulation of the events duplicates: this behavior should probably extracted in a separate function

Probably another small PR


> > * possibility to drop duplicated events rather than sum their modulation
> 
> If this needed(?)

probably not unless users ask for it

Resolving patch:
diff --git a/nilearn/glm/first_level/design_matrix.py b/nilearn/glm/first_level/design_matrix.py
--- a/nilearn/glm/first_level/design_matrix.py
+++ b/nilearn/glm/first_level/design_matrix.py
@@ -40,7 +40,10 @@
 
 from nilearn._utils import fill_doc
 from nilearn.glm._utils import full_rank
-from nilearn.glm.first_level.experimental_paradigm import check_events
+from nilearn.glm.first_level.experimental_paradigm import (
+    check_events,
+    handle_modulation_of_duplicate_events,
+)
 from nilearn.glm.first_level.hemodynamic_models import (
     _orthogonalize,
     compute_regressor,
@@ -233,7 +236,15 @@ def _convolve_regressors(
     """
     regressor_names = []
     regressor_matrix = None
-    trial_type, onset, duration, modulation = check_events(events)
+
+    events_copy = check_events(events)
+    cleaned_events = handle_modulation_of_duplicate_events(events_copy)
+
+    trial_type = cleaned_events["trial_type"].values
+    onset = cleaned_events["onset"].values
+    duration = cleaned_events["duration"].values
+    modulation = cleaned_events["modulation"].values
+
     for condition in np.unique(trial_type):
         condition_mask = trial_type == condition
         exp_condition = (
diff --git a/nilearn/glm/first_level/experimental_paradigm.py b/nilearn/glm/first_level/experimental_paradigm.py
--- a/nilearn/glm/first_level/experimental_paradigm.py
+++ b/nilearn/glm/first_level/experimental_paradigm.py
@@ -24,9 +24,6 @@ def check_events(events):
     It is valid if the events data has ``'onset'`` and ``'duration'`` keys
     with numeric non NaN values.
 
-    This function also handles duplicate events
-    by summing their modulation if they have one.
-
     Parameters
     ----------
     events : pandas DataFrame
@@ -34,6 +31,11 @@ def check_events(events):
 
     Returns
     -------
+    events : pandas DataFrame
+        Events data that describes a functional experimental paradigm.
+
+    The dataframe has the following columns:
+
     trial_type : array of shape (n_events,), dtype='s'
         Per-event experimental conditions identifier.
         Defaults to np.repeat('dummy', len(onsets)).
@@ -95,19 +97,11 @@ def check_events(events):
 
     _check_unexpected_columns(events_copy)
 
-    events_copy = _handle_modulation(events_copy)
-
-    cleaned_events = _handle_duplicate_events(events_copy)
-
-    trial_type = cleaned_events["trial_type"].values
-    onset = cleaned_events["onset"].values
-    duration = cleaned_events["duration"].values
-    modulation = cleaned_events["modulation"].values
-    return trial_type, onset, duration, modulation
+    return _handle_modulation(events_copy)
 
 
 def _check_columns(events):
-    # Column checks
+    """Check events has onset and duration numeric columns with no NaN."""
     for col_name in ["onset", "duration"]:
         if col_name not in events.columns:
             raise ValueError(
@@ -129,6 +123,7 @@ def _check_columns(events):
 
 
 def _handle_missing_trial_types(events):
+    """Create 'dummy' events trial_type if the column is not present."""
     if "trial_type" not in events.columns:
         warnings.warn(
             "'trial_type' column not found in the given events data."
@@ -138,6 +133,7 @@ def _handle_missing_trial_types(events):
 
 
 def _check_null_duration(events):
+    """Warn if there are events with null duration."""
     conditions_with_null_duration = events["trial_type"][
         events["duration"] == 0
     ].unique()
@@ -149,6 +145,7 @@ def _check_null_duration(events):
 
 
 def _handle_modulation(events):
+    """Set the modulation column to 1 if it is not present."""
     if "modulation" in events.columns:
         print(
             "A 'modulation' column was found in "
@@ -163,8 +160,7 @@ def _handle_modulation(events):
 
 
 def _check_unexpected_columns(events):
-    # Warn for each unexpected column that will
-    # not be used afterwards
+    """Warn for each unexpected column that will not be used afterwards."""
     unexpected_columns = list(set(events.columns).difference(VALID_FIELDS))
     if unexpected_columns:
         warnings.warn(
@@ -185,7 +181,11 @@ def _check_unexpected_columns(events):
 STRATEGY = {"modulation": "sum"}
 
 
-def _handle_duplicate_events(events):
+def handle_modulation_of_duplicate_events(events):
+    """Deal with modulation of duplicate events if they have one.
+
+    Currently the strategy is to sum the modulation values of duplicate events.
+    """
     cleaned_events = (
         events.groupby(COLUMN_DEFINING_EVENT_IDENTITY, sort=False)
         .agg(STRATEGY)
diff --git a/nilearn/plotting/matrix_plotting.py b/nilearn/plotting/matrix_plotting.py
--- a/nilearn/plotting/matrix_plotting.py
+++ b/nilearn/plotting/matrix_plotting.py
@@ -8,6 +8,8 @@
 from mpl_toolkits.axes_grid1 import make_axes_locatable
 from scipy.cluster.hierarchy import leaves_list, linkage, optimal_leaf_ordering
 
+from nilearn.glm.first_level.experimental_paradigm import check_events
+
 from .._utils import fill_doc
 
 with warnings.catch_warnings():
@@ -496,6 +498,10 @@ def plot_event(model_event, cmap=None, output_file=None, **fig_kwargs):
     if isinstance(model_event, pd.DataFrame):
         model_event = [model_event]
 
+    for i, event in enumerate(model_event):
+        event_copy = check_events(event)
+        model_event[i] = event_copy
+
     n_runs = len(model_event)
     figure, ax = plt.subplots(1, 1, **fig_kwargs)
 


Test patch:
diff --git a/nilearn/glm/tests/test_paradigm.py b/nilearn/glm/tests/test_paradigm.py
--- a/nilearn/glm/tests/test_paradigm.py
+++ b/nilearn/glm/tests/test_paradigm.py
@@ -14,6 +14,9 @@
 
 from nilearn._utils.data_gen import basic_paradigm
 from nilearn.glm.first_level import check_events
+from nilearn.glm.first_level.experimental_paradigm import (
+    handle_modulation_of_duplicate_events,
+)
 
 from ._utils import (
     block_paradigm,
@@ -28,20 +31,21 @@
 
 def test_check_events():
     events = basic_paradigm()
-    trial_type, _, _, modulation = check_events(events)
+    events_copy = check_events(events)
 
     # Check that given trial type is right
     assert_array_equal(
-        trial_type, ["c0", "c0", "c0", "c1", "c1", "c1", "c2", "c2", "c2"]
+        events_copy["trial_type"],
+        ["c0", "c0", "c0", "c1", "c1", "c1", "c2", "c2", "c2"],
     )
 
     # Check that missing modulation yields an array one ones
-    assert_array_equal(modulation, np.ones(len(events)))
+    assert_array_equal(events_copy["modulation"], np.ones(len(events)))
 
     # Modulation is provided
     events["modulation"] = np.ones(len(events))
-    _, _, _, mod = check_events(events)
-    assert_array_equal(mod, events["modulation"])
+    events_copy = check_events(events)
+    assert_array_equal(events_copy["modulation"], events["modulation"])
 
 
 def test_check_events_errors():
@@ -86,11 +90,11 @@ def test_check_events_warnings():
     # Missing trial type
     events = events.drop(columns=["trial_type"])
     with pytest.warns(UserWarning, match="'trial_type' column not found"):
-        trial_type, onset, duration, modulation = check_events(events)
+        events_copy = check_events(events)
 
     # Check that missing trial type yields a 'dummy' array
-    assert len(np.unique(trial_type)) == 1
-    assert trial_type[0] == "dummy"
+    assert len(np.unique(events_copy["trial_type"])) == 1
+    assert events_copy["trial_type"][0] == "dummy"
 
     # An unexpected field is provided
     events["foo"] = np.zeros(len(events))
@@ -101,29 +105,12 @@ def test_check_events_warnings():
             "in events data will be ignored: foo"
         ),
     ):
-        trial_type2, onset2, duration2, modulation2 = check_events(events)
-
-    assert_array_equal(trial_type, trial_type2)
-    assert_array_equal(onset, onset2)
-    assert_array_equal(duration, duration2)
-    assert_array_equal(modulation, modulation2)
+        events_copy2 = check_events(events)
 
-
-def test_duplicate_events():
-    """Test the function check_events when the paradigm contains
-    duplicate events.
-
-    """
-    events = duplicate_events_paradigm()
-
-    # Check that a warning is given to the user
-    with pytest.warns(UserWarning, match="Duplicated events were detected."):
-        trial_type, onset, duration, modulation = check_events(events)
-    assert_array_equal(trial_type, ["c0", "c0", "c0", "c1", "c1"])
-    assert_array_equal(onset, [10, 30, 70, 10, 30])
-    assert_array_equal(duration, [1.0, 1.0, 1.0, 1.0, 1.0])
-    # Modulation was updated
-    assert_array_equal(modulation, [1, 1, 2, 1, 1])
+    assert_array_equal(events_copy["trial_type"], events_copy2["trial_type"])
+    assert_array_equal(events_copy["onset"], events_copy2["onset"])
+    assert_array_equal(events_copy["duration"], events_copy2["duration"])
+    assert_array_equal(events_copy["modulation"], events_copy2["modulation"])
 
 
 def write_events(events, tmpdir):
@@ -174,3 +161,22 @@ def test_check_events_nan_designs(design):
         ValueError, match=("The following column must not contain nan values:")
     ):
         check_events(design())
+
+
+def test_sum_modulation_of_duplicate_events():
+    """Test the function check_events when the paradigm contains
+    duplicate events.
+
+    """
+    events = duplicate_events_paradigm()
+
+    # Check that a warning is given to the user
+    with pytest.warns(UserWarning, match="Duplicated events were detected."):
+        events_copy = handle_modulation_of_duplicate_events(events)
+    assert_array_equal(
+        events_copy["trial_type"], ["c0", "c0", "c0", "c1", "c1"]
+    )
+    assert_array_equal(events_copy["onset"], [10, 30, 70, 10, 30])
+    assert_array_equal(events_copy["duration"], [1.0, 1.0, 1.0, 1.0, 1.0])
+    # Modulation was updated
+    assert_array_equal(events_copy["modulation"], [1, 1, 2, 1, 1])