Repository: mne-tools/mne-python

Issue:
Add `count_events()` function
### Describe the new feature or enhancement

Does anyone else feel the need for a `count_events()` function? I need it all the time to check if the number of specific event types are what I expect.

### Describe your proposed implementation

It's easy enough to implement, for example like this:

```python
from collections import defaultdict
from numpy import bincount

def count_events(events):
    counts = bincount(events[:, 2])
    return defaultdict(lambda: 0, {i: count for i, count in enumerate(counts) if count > 0})
```

This produces a nice overview in a defaultdict like this:

```
defaultdict(<function __main__.count_events.<locals>.<lambda>()>,
            {111: 60, 121: 60, 201: 111, 202: 11, 211: 60, 221: 60})
```

The reason for a defaultdict is that users can access non-existing events and will get 0.

### Describe possible alternatives

Like I said, it is not hard to write this every time it is needed. But my point is that I think it is needed frequently enough to warrant a dedicated function in MNE.

### Additional context

_No response_


Discussion:
seems useful. This might be a bit simpler? uses builtin `list.count()` method instead of bincount.

```py
unique_events = np.unique(events[:, 2])
counts = {item: events[:, 2].tolist().count(item) for item in unique_events}
return defaultdict(lambda: 0, counts)
```

Other option is `pd.value_counts(events[:, 2]).to_dict()` but then you need pandas installed, which is a soft dependency at present.
Nah, I'd rather not use pandas just for that, let's stick with NumPy. I don't have a preference for either implementation, they seem about equally simple.
The other question is where would you put it? Two options are `mne.count_events()` and `mne.event.count_events()`, what would everyone prefer?
> The reason for a defaultdict is that users can access non-existing events and will get 0.

I cannot say that I like this approach. I'd expect it to throw an `IndexError` instead, based on my daily work in the "scientific Python ecosystem".
> `mne.count_events()` and `mne.event.count_events()`,

I'd actually decide between `mne.count_events()` and `mne.events.count()`, no? 
> > The reason for a defaultdict is that users can access non-existing events and will get 0.
> 
> I cannot say that I like this approach. I'd expect it to throw an `IndexError` instead, based on my daily work in the "scientific Python ecosystem".

That was my initial reaction too (though it would be `KeyError` for a dict). However, imagine a scenario where your experiment has an event `77` but in this particular script you've done something to subset the data and none of the event `77`s are left.  Would it be more useful to return a `0` or to error out?  To me it's not really clear which is better.
@drammock that's exactly why I used a defaultdict. I frequently do this kind of subsetting, or sometimes a particular event type is not present for a subject, e.g. to indicate an incorrect response - but this subject had only correct responses. A normal dict would require a try/except or `.get(type, 0)`, which is cumbersome.
> , imagine a scenario where your experiment has an event `77` but in this particular script you've done something to subset the data and none of the event `77`s are left. Would it be more useful to return a `0` or to error out? To me it's not really clear which is better.

I'd suggest to control this behavior via a function parameter, then, and default to **not** returning a `defaultdict`
I am also starting to wonder if we shouldn't consider adding an `Events` class, which acts like a NumPy array for backward-compatibility but also has a `.count()` method.
I'm open to the idea of an `Events` class that is a thin wrapper of `np.ndarray`. But even if that is the route we go, I think @cbrnr's idea of counting everything at once is a good one so I'd lobby for an API like `Events.value_counts()` rather than `Events.count(77)`
> I'd suggest to control this behavior via a function parameter, then, and default to **not** returning a `defaultdict`

you mean something like `on_missing='raise'`?  And if `on_missing='ignore'` or `'warn'` then just do a try-except and return 0 in the except (possibly accompanied by a warning)?
An `Events` class is a good idea in principle, but then we should discuss merging it with our `Annotations`. This historic distinction is something that people have a hard time understanding, and we could create a class which transparently handles both events and annotations.

But this will be a larger change and should be discussed separately. Right now, the question is whether or not we should add the proposed function for existing events or not.

Regarding the behavior of returning 0 by default, I think that's the right thing to do. If I ask "how many events of type 62 are there?", I expect to get 0 if there are none. Even if you would like to control that behavior with a parameter, this should be the default, and raising an error should be optional. I think there's nothing wrong or noteworthy if you query an event that occurs zero times, just return `0`. I have not yet seen an example where this might be problematic other than users might expect it from other Python functions.
> If I ask "how many events of type 62 are there?", I expect to get 0 if there are none.

I fully agree. But I don't agree that this question should be phrased using a dictionary. If it's a function call, I'm alright with it. If it's a dictionary that suddenly spits out results even if the keys I'm requesting are missing, I don't like it -- it deviates greatly from what I I would expect in MNE (based on experience), and introduces an inconsistency with the rest of my code.

> > I'd suggest to control this behavior via a function parameter, then, and default to **not** returning a `defaultdict`
> 
> 
> 
> you mean something like `on_missing='raise'`?  And if `on_missing='ignore'` or `'warn'` then just do a try-except and return 0 in the except (possibly accompanied by a warning)?

No, I only meant a switch to either return a normal dict or the proposed defaultdict. But now that I come to think of it again, I don't like my suggestion anymore.

Why not simply add an optional parameter to pass the requested event codes and return a dict where each code is a key? Then I would be okay with simply putting the value 0 in places where the key doesn't exist in the events array, because things are more explicit then. And if the parameter is omitted, only add dict keys for all event codes that are actually present in the data. And never make use of a defaultdict.
I often do this in my code (assuming the hypothetical function `count_events()` exists):

```python
counts = mne.count_events(events)
n_responses = counts[1] + counts[2] + counts[3]
```

Events 1, 2, and 3 are three different possible responses. Now depending on the subjects, some data might have zero events of type 3. With the proposed solution, I could use my code above. It is simple and elegant, and a defaultdict is also Pythonic and nothing magical/unexpected.

How would this code look like using your proposed solution? I do not understand your arguments other than claiming it is inconsistent (with the rest of MNE?).
```python
counts = count_events(events, ids=(1, 2, 3))
n_resp = sum(list(counts.values()))
```
and if we returned a DataFrame or Series instead, it could be as simple as `n_resp = counts.sum()`

The point about (in)consistency to me is not a minor one, but actually a huge deal. MNE is complicated to use already, and I believe making things less (instead of more) consistent is not something we should do
Looks good, then let's do it like that!

Last open question: where do we put the function? The consistent location would probably be `mne.count_events()`.
I think the public namespace should be `mne.count_events()`, yes

But I'm not sure yet if we agreed upon which data type to return?

Because if it's cool to return e.g. a Series we could remove the `ids` param I proposed and suggest something like this:

```python
counts = count_events(events)
n_resp = counts[counts.index.isin([1, 2, 3])].sum()
```
Or we could have a DataFrame with columns `id` and `count`:

```python
n_resp = counts.loc[counts['id'].isin([1, 2, 3]), 'count']).sum()
```
> Because if it's cool to return e.g. a Series we could remove the `ids` param I proposed and suggest something like this:
> 
> ```python
> counts = count_events(events)
> n_resp = counts[counts.index.isin([1, 2, 3])].sum()
> ```
> 
> Or we could have a DataFrame with columns `id` and `count`:
> 
> ```python
> n_resp = counts.loc[counts['id'].isin([1, 2, 3]), 'count']).sum()
> ```

But obviously this looks kind of scary to a beginner, right?

So maybe:

- keep the `ids` parameter to filter down event IDs
- but still return a Series so users can simply call `.sum()` to get the count

Edit:

Example:
```python
counts: pd.Series = count_events(events, ids=(1, 2, 3))
n_resp = counts.sum()
```

This would actually be my favorite! But then again, returning a Series is inconsistent with most of the rest of MNE too, so I'm contradicting myself here 😅
(I edited my above post to add an example for returning a Series)
Let's not return any pandas objects, I would like to avoid using optional dependencies. Sure everything can always be made even more convenient, but your example is simple enough I think.
Ok, a dict it is!
Proposal:

```python
def count_events(
    events: ndarray,
    ids: Iterable[int] | None = None,
    on_missing: Literal['raise', 'warn', 'ignore'] = 'raise'  # ignored if no ids are passed
) -> dict[int, int]:
    ...
    if ids and on_missing != 'ignore' and not all([i in events[:, -1] for i in ids]):
        raise IndexError()  # or ValueError? 
    elif ids and on_missing in ('warn', 'ignore'):
        # set counts[id] to 0 for missing IDs
    
    ...
```

Or we omit the entire on_missing parameterand always go the "ignore" or "warn" route?
IMO we don't need this parameter. If `ids is None` (the default), we return a normal dict with event types as keys and their frequencies as values. If, however, `ids` is a list of `int`, the dict will consist only of keys corresponding to the values in `ids` – and if a specific event type does not occur in the data, its value in the dict will be zero.
Ok let's do this!
```python
from numpy import bincount


def count_events(events, ids=None):
    counts = bincount(events[:, 2])
    counts = {i: count for i, count in enumerate(counts) if count > 0}
    if ids is not None:
        return {id: counts.get(id, 0) for id in ids}
    return counts


events = np.array([[0, 0, 1], [0, 0, 1], [0, 0, 5]])
```

Behaves as follows:

```
>>> count_events(events)
{1: 2, 5: 1}
>>> count_events(events, ids=[1, 5])
{1: 2, 5: 1}
>>> count_events(events, ids=[1, 11])
{1: 2, 11: 0}
```

I like it. Is everyone OK with putting it into `mne.count_events()`?
@hoechenberger your previous example

```python
n_resp = sum(list(counts.values()))
```

can be simplified to

```python
n_resp = sum(counts.values())
```

, which is very nice!
Oh I didn't know this was possible! Perfect!!!

Resolving patch:
diff --git a/mne/__init__.py b/mne/__init__.py
--- a/mne/__init__.py
+++ b/mne/__init__.py
@@ -43,7 +43,7 @@
                   compute_covariance, whiten_evoked, make_ad_hoc_cov)
 from .event import (read_events, write_events, find_events, merge_events,
                     pick_events, make_fixed_length_events, concatenate_events,
-                    find_stim_steps, AcqParserFIF)
+                    find_stim_steps, AcqParserFIF, count_events)
 from ._freesurfer import (head_to_mni, head_to_mri, read_talxfm,
                           get_volume_labels_from_aseg, read_freesurfer_lut,
                           vertex_to_mni, read_lta)
diff --git a/mne/event.py b/mne/event.py
--- a/mne/event.py
+++ b/mne/event.py
@@ -1499,3 +1499,37 @@ def match_event_names(event_names, keys, *, on_missing='raise'):
 
     matches = sorted(set(matches))  # deduplicate if necessary
     return matches
+
+
+def count_events(events, ids=None):
+    """Count events.
+
+    Parameters
+    ----------
+    events : ndarray, shape (N, 3)
+        The events array (consisting of N events).
+    ids : array-like of int | None
+        If ``None``, count all event types present in the input. If array-like
+        of int, count only those event types given by ``ids``.
+
+    Returns
+    -------
+    counts : dict
+        A dictionary containing the event types as keys with their counts as
+        values.
+
+    Examples
+    --------
+        >>> events = np.array([[0, 0, 1], [0, 0, 1], [0, 0, 5]])
+        >>> count_events(events)
+        {1: 2, 5: 1}
+        >>> count_events(events, ids=[1, 5])
+        {1: 2, 5: 1}
+        >>> count_events(events, ids=[1, 11])
+        {1: 2, 11: 0}
+    """
+    counts = np.bincount(events[:, 2])
+    counts = {i: count for i, count in enumerate(counts) if count > 0}
+    if ids is not None:
+        return {id: counts.get(id, 0) for id in ids}
+    return counts


Test patch:
diff --git a/mne/tests/test_event.py b/mne/tests/test_event.py
--- a/mne/tests/test_event.py
+++ b/mne/tests/test_event.py
@@ -14,7 +14,7 @@
 from mne import (read_events, write_events, make_fixed_length_events,
                  find_events, pick_events, find_stim_steps, pick_channels,
                  read_evokeds, Epochs, create_info, compute_raw_covariance,
-                 Annotations)
+                 Annotations, count_events)
 from mne.io import read_raw_fif, RawArray
 from mne.event import (define_target_events, merge_events, AcqParserFIF,
                        shift_time_events, match_event_names)
@@ -638,3 +638,11 @@ def test_match_event_names():
         event_names=event_names, keys=keys, on_missing='ignore'
     )
     assert matches == []
+
+
+def test_count_events():
+    """Test counting events."""
+    events = np.array([[0, 0, 1], [0, 0, 1], [0, 0, 5]])
+    assert count_events(events) == {1: 2, 5: 1}
+    assert count_events(events, ids=(1, 5)) == {1: 2, 5: 1}
+    assert count_events(events, ids=(1, 11)) == {1: 2, 11: 0}