Repository: bycycle-tools/bycycle

Issue:
optimize function check_min_burst_cycles
Hi there!

I noticed that the `check_min_burst_cycles` function can be optimized a bit. I profiled a proposed refactor using my M1 Macbook Pro -- I'm curious what this looks like on other machines. Not sure how significantly this will impact most users -- in my simulated example, it starts to become noticeable at ~1e7 cycles. Perhaps a refactor would be useful for continuous EEG with lots of channels?

![min_n_cycles_3](https://user-images.githubusercontent.com/113465276/235267632-da9ca623-6fe1-4a3d-af31-d910e6a4947b.png)
![min_n_cycles_10](https://user-images.githubusercontent.com/113465276/235267788-fad5cf45-c847-4040-bb11-14efe3d7630c.png)

(omitting docstring and `check_param_range`):
```python
def check_min_burst_cycles_new(is_burst, min_n_cycles=3):
    # extract transition indices
    diff = np.diff(is_burst)
    transitions = np.flatnonzero(diff) + 1
    ons, offs = transitions[0::2], transitions[1::2]

    # select only segments with long enough duration
    durations = np.diff([ons, offs], axis=0)
    durations = np.atleast_1d(durations.squeeze())  # careful with dimensions...
    ons = ons[durations >= min_n_cycles]
    offs = offs[durations >= min_n_cycles]

    # construct bool time series from transition indices
    out = np.zeros_like(is_burst, dtype=bool)
    for on, off in zip(ons, offs):
        out[on:off] = True

    return out
```

If you'd like to repro:

```python
from time import time
import numpy as np

np.random.seed(0)
min_n_cycles = 3

old_times = []
new_times = []
cycle_count = np.power(10, range(1, 9))
for count in cycle_count:
    old_times_tmp = []
    new_times_tmp = []
    for rep in range(10):
        # simulate random data -- this is admittedly assuming a certain distribution
        a = np.random.random(count) > .5
        a[0] = False
        a[-1] = False

        t0 = time()
        new = check_min_burst_cycles_new(a, min_n_cycles=min_n_cycles)
        new_times_tmp.append(time() - t0)

        t0 = time()
        old = check_min_burst_cycles(a, min_n_cycles=min_n_cycles)
        old_times_tmp.append(time() - t0)

        # make sure I'm getting the same result...
        np.testing.assert_equal(old, new)
    new_times.append(np.median(new_times_tmp))
    old_times.append(np.median(old_times_tmp))
```

... and plot
```python
import matplotlib.pyplot as plt
plt.loglog(cycle_count, old_times, label="old")
plt.loglog(cycle_count, new_times, label="new")
plt.xlabel("Cycle count")
plt.ylabel("Median compute time (s)")
plt.title(f"Profiling new vs old algo (min_n_cycles={min_n_cycles})")
plt.legend()
```

Comments:

Patch:
diff --git a/bycycle/burst/amp.py b/bycycle/burst/amp.py
index ffc099c..10375c2 100644
--- a/bycycle/burst/amp.py
+++ b/bycycle/burst/amp.py
@@ -1,4 +1,5 @@
 """Detect bursts: amplitude threshold approach."""
+import numpy as np
 
 from bycycle.utils.checks import check_param_range
 from bycycle.burst.utils import check_min_burst_cycles
@@ -42,6 +43,7 @@ def detect_bursts_amp(df_features, burst_fraction_threshold=1, min_n_cycles=3):
 
     # Determine cycles that are defined as bursting throughout the whole cycle
     is_burst = [frac >= burst_fraction_threshold for frac in df_features['burst_fraction']]
+    is_burst = np.array(is_burst)
 
     df_features['is_burst'] = check_min_burst_cycles(is_burst, min_n_cycles=min_n_cycles)
 
diff --git a/bycycle/burst/cycle.py b/bycycle/burst/cycle.py
index 6ff4c6b..008d101 100644
--- a/bycycle/burst/cycle.py
+++ b/bycycle/burst/cycle.py
@@ -90,6 +90,7 @@ def detect_bursts_cycles(df_features, amp_fraction_threshold=0., amp_consistency
 
     # Set the burst status for each cycle as the answer across all criteria
     is_burst = amp_fraction & amp_consistency & period_consistency & monotonicity
+    is_burst = is_burst.to_numpy()
 
     # Set the first and last cycles to not be part of a burst
     is_burst[0] = False
diff --git a/bycycle/burst/utils.py b/bycycle/burst/utils.py
index e57ea98..de82a8b 100644
--- a/bycycle/burst/utils.py
+++ b/bycycle/burst/utils.py
@@ -31,23 +31,28 @@ def check_min_burst_cycles(is_burst, min_n_cycles=3):
     array([False, False, False, False,  True,  True,  True,  True, False])
     """
 
-    # Ensure argument is within valid range
-    check_param_range(min_n_cycles, 'min_n_cycles', (0, np.inf))
-
-    temp_cycle_count = 0
+    if not isinstance(is_burst, np.ndarray):
+        raise ValueError("Argument 'is_burst' must be a numpy array!")
 
-    for idx, bursting in enumerate(is_burst):
+    # handle special case where input array is empty
+    if len(is_burst) == 0:
+        return is_burst
 
-        if bursting:
-            temp_cycle_count += 1
+    # Ensure argument is within valid range
+    check_param_range(min_n_cycles, 'min_n_cycles', (0, np.inf))
 
-        else:
+    # extract transition indices
+    diff = np.diff(is_burst, prepend=0, append=0)
+    transitions = np.flatnonzero(diff)
+    ons, offs = transitions[0::2], transitions[1::2]
 
-            if temp_cycle_count < min_n_cycles:
-                for c_rm in range(temp_cycle_count):
-                    is_burst[idx - 1 - c_rm] = False
+    # select only segments with long enough duration
+    durations = offs - ons
+    too_short = durations < min_n_cycles
 
-            temp_cycle_count = 0
+    # construct bool time series from transition indices
+    for silence_on, silence_off in zip(ons[too_short], offs[too_short]):
+        is_burst[silence_on:silence_off] = False
 
     return is_burst
 


Test patch:
diff --git a/bycycle/tests/burst/test_utils.py b/bycycle/tests/burst/test_utils.py
index 81af247..c63498e 100644
--- a/bycycle/tests/burst/test_utils.py
+++ b/bycycle/tests/burst/test_utils.py
@@ -1,6 +1,8 @@
 """Test burst.utils."""
 
 import numpy as np
+import pandas as pd
+import pytest
 
 from bycycle.features import compute_features
 from bycycle.burst.utils import *
@@ -9,19 +11,49 @@ from bycycle.burst.utils import *
 ###################################################################################################
 ###################################################################################################
 
-def test_check_min_burst_cycles():
+@pytest.mark.parametrize("min_n_cycles", [2, 3])
+def test_check_min_burst_cycles(min_n_cycles):
 
-    is_burst = np.array([True, True, True, False])
-    is_burst_check = check_min_burst_cycles(is_burst, min_n_cycles=3)
+    is_burst = np.array([False, True, True, False, False])
+
+    is_burst_check = check_min_burst_cycles(is_burst.copy(), min_n_cycles=min_n_cycles)
+
+    burst_should_be_kept = min_n_cycles < 3
+    burst_kept = (is_burst == is_burst_check).all()
+
+    assert burst_kept == burst_should_be_kept
+
+
+@pytest.mark.parametrize("side", ["start", "end"])
+def test_check_min_burst_cycles_bursting_at_side(side):
+
+    min_n_cycles = 5
+    is_burst = [True] * min_n_cycles + [False]
+    is_burst = np.flip(is_burst) if side == "end" else np.array(is_burst)
+
+    is_burst_check = check_min_burst_cycles(is_burst.copy(), min_n_cycles=min_n_cycles)
 
     assert (is_burst == is_burst_check).all()
 
-    is_burst = np.array([True, False, True, False])
-    is_burst_check = check_min_burst_cycles(is_burst, min_n_cycles=3)
+
+def test_check_min_burst_cycles_no_bursts():
+
+    num_cycles = 5
+    is_burst = np.zeros(num_cycles, dtype=bool)
+
+    is_burst_check = check_min_burst_cycles(is_burst.copy(), min_n_cycles=3)
 
     assert not any(is_burst_check)
 
 
+def test_check_min_burst_cycles_empty_input():
+
+    is_burst = np.array([])
+    is_burst_check = check_min_burst_cycles(is_burst.copy(), min_n_cycles=3)
+
+    assert not len(is_burst_check)
+
+
 def test_recompute_edges(sim_args_comb):
 
     # Grab sim arguments from fixture