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

compute_synchrony_metrics update #2605

Merged
merged 29 commits into from
Apr 16, 2024

Conversation

chrishalcrow
Copy link
Collaborator

@chrishalcrow chrishalcrow commented Mar 20, 2024

(Another go of #2528, after the SortingAnalyzer refactor)

Planning to standardise, correctness test and optimise the quality metrics module. First one: the compute_synchrony_metrics function.

Two main things in this:

  • The generating function synthesize_random_firings and synthesize_poisson_spike_vectors were adjusted so that you can insert specific spikes, useful for testing. These are used in the new tests.
  • A new function compute_synchrony_counts is introduced, which computes the synchrony counts.

One thing I'm not sure about: The documentation said that compute_synchrony_metrics returns a dict, but I think we should export a namedtuple, which matches most other quality metrics.

I adjusted the algorithm in compute_synchrony_counts, which should be easier to understand and is significantly faster. Here are some benchmarking results, with times and create a log-log plot to check the algorithm's complexity. About 30x faster!

benchmarch_sync

Benchmarked using the following code:

import numpy as np
from time import perf_counter

from spikeinterface.core import synthesize_random_firings
from spikeinterface.qualitymetrics import get_synchrony_counts

unit_ids = np.arange(100)

Ns = 10**np.array(np.arange(3,5.1,0.1))

times_by_size=[]
how_many = 100

for train_length in Ns:

    times_list = []

    for _ in range(0,how_many):

        spike_train = synthesize_random_firings(num_units=100, duration=train_length/1000, firing_rates=10.0)

        start = perf_counter()
        get_synchrony_counts(spike_train, np.array((2,4,8)), list(unit_ids))        
        end = perf_counter()

        times_list.append(end - start)

    times_by_size.append( np.median( times_list ) )

@alejoe91 alejoe91 added the qualitymetrics Related to qualitymetrics module label Mar 20, 2024
@alejoe91
Copy link
Member

Thanks @chrishalcrow

Can you heck why tests are failing?

@alejoe91 alejoe91 added refactor Refactor of code, with no change to functionality performance Performance issues/improvements labels Mar 20, 2024
@chrishalcrow
Copy link
Collaborator Author

Thanks @chrishalcrow

Can you heck why tests are failing?

Yes, it's to do with me originally changing the output from namedtuple to a dict (as documented). I'm changing back now...

src/spikeinterface/core/generate.py Outdated Show resolved Hide resolved

return (times, labels)
return structured_insertions
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same coment here about the output. Input and output are your contracts with your users. We should be careful with changing them (and careful to define them in the first place)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, I'll add some documentation. I think this (and the add_insertions) functions don't have much use outside of testing. Should I "hide" them (i.e. make them _add_insertions etc)?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you think that they will only be used for internal testing then the best is to have them in the corresponding testing module I feel.

I think hiding by default is a good option and then you make it public once you feel confident about the interface and purpose. That minimizes the risk if you want to change the function later. But this is my opinion, I don't think that anyone else would find any problems if you want to add some functions.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer to keep them close to the synthesize_stuff functions, since they are closely related. I'm a fan of the "related code should be close together" mindset. Maybe they should all be in the testing module? But maybe for another time...

Great, I've hidden it and also added some documentation. Best of all worlds.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a good decision. If we ever use the function more and we are decided on the signature, then we can make it public.

src/spikeinterface/core/generate.py Outdated Show resolved Hide resolved
----------
spikes : memmap
The spike train
synchrony_sizes : numpy array
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this given in seconds, samples, milliseconds?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've made the description more accruate (since it's actually a structured numpy array). One of the fields is "sample_index" which clarifies the unit.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was talking about the synchrony_sizes

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, sorry. These are the number of synchronous events you want to count. So if you want to see when two or four spikes fire at the same time you use synchrony_sizes = (2,4). So it's an integer, and I think it will be clear for anyone who knows enough about the metric that their using it.

Copy link
Collaborator

@h-mayorquin h-mayorquin Mar 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I guess my lack of familarity with the metric is evident then. I somehow imagined that it was the windows in which an event would be counted as syncronous. Thanks for explaining it.

@h-mayorquin
Copy link
Collaborator

Tip:
You have https://numpy.org/doc/stable/reference/generated/numpy.logspace.html to generate your samples.

Benchamark comments:

  • I think you should fix your seed in your benchmark explictly.
  • I find the spike_length variable hard to think about. I suggest using time and show us the units. Does it scale well to the relevant domain of hour long sortings?
  • Finally, I would be curios to see how this scales with the number of units as well.

@chrishalcrow
Copy link
Collaborator Author

Tip: You have https://numpy.org/doc/stable/reference/generated/numpy.logspace.html to generate your samples.

Benchamark comments:

  • I think you should fix your seed in your benchmark explictly.
  • I find the spike_length variable hard to think about. I suggest using time and show us the units. Does it scale well to the relevant domain of hour long sortings?
  • Finally, I would be curios to see how this scales with the number of units as well.

Oooh, thanks for the logspace function.

Maybe it would be helpful to say: the new algorithm is expected to be faster. I was able to replace the use of np.complexity, isin and count_nonzero (I think complexity is the heavy function of these) with some simple selections (like unique_spike_index[counts >= 2]).

Spike_length is just the number of spikes in the spike vector. So for generic data, this increases linearly with time.

I’ve been using a 30 minute 16 channel recording from my lab, which contains 70,000 spikes after sorting. Is this realistic? I’ve also been using this to make sure my algorithm is consistent with the old results. My tests go up to 100,000 spikes. What would a good number to go up to?

I'm testing with unit number now, going up to 1000 units. Is that high enough? The complexity function changes it’s method at some point, and I’m trying to figure out how and when and will report back soon! But the new code is still substantially faster at e.g. 1k units with 100k spikes.

@h-mayorquin
Copy link
Collaborator

Spike_length is just the number of spikes in the spike vector. So for generic data, this increases linearly with time.

On a second thought, this seems more amenable to control and more relevant than duration. It is just probably me that does not have a good intuitive grasp of this metric while duration I understand better. How are you controlling this though, given that you are passing this parameter in duration divided by 1000?

I'm testing with unit number now, going up to 1000 units. Is that high enough? The complexity function changes it’s method at some point, and I’m trying to figure out how and when and will report back soon! But the new code is still substantially faster at e.g. 1k units with 100k spikes.

1000 I have been told than 1000 units is like a very high number.

Bear in mind that the comments I made about the benchmark is just me being curios and suggestions. As long as Sam and Alessio are OK with the rest I would not bother if you don't care about it. The thing that I really feel strongly about is for not changing the signature and output of synthesize_poisson_spike_vector.

@chrishalcrow
Copy link
Collaborator Author

Spike_length is just the number of spikes in the spike vector. So for generic data, this increases linearly with time.

On a second thought, this seems more amenable to control and more relevant than duration. It is just probably me that does not have a good intuitive grasp of this metric while duration I understand better. How are you controlling this though, given that you are passing this parameter in duration divided by 1000?

I'm testing with unit number now, going up to 1000 units. Is that high enough? The complexity function changes it’s method at some point, and I’m trying to figure out how and when and will report back soon! But the new code is still substantially faster at e.g. 1k units with 100k spikes.

1000 I have been told than 1000 units is like a very high number.

Bear in mind that the comments I made about the benchmark is just me being curios and suggestions. As long as Sam and Alessio are OK with the rest I would not bother if you don't care about it. The thing that I really feel strongly about is for not changing the signature and output of synthesize_poisson_spike_vector.

Hello,

I learned a few things from your comments and trying to recreate my previous benchmarking. Thanks! Before: I refactored the code, creating a new function compute_synchrony_count. Then I did a speed test on the new function using the old algorithm against an optimised version of it. This is hard for others to reproduce since you can’t easily run compute_synchrony_count. So now I’m benchmarking against the full compute_synchrony_metrics function. This should be closer to “real world” use than before and easier for anyone to check. And I’ve hopefully made the wording/details of the plots more obvious.

The old benchmarking results are basically unchanged.

And I’ve now done more benchmarking, with changing units. First, here’s a plot of timings with increasing units N but fixed spike train length (of about 50k: in our lab, this is the number of spikes in a typical 20 minute recording). The new algorithm grows linearly with the number of spikes. The old algorithm, which uses more complex functions, changes tactic at ~600 units (I think it’s np.unique that changes its tactic…). Both its tactics look like O(N) algorithms. But even when it changes, it is still about 10x slower than the new one.

benchmarch_sync_N

I also tried keeping the duration and firing rates fixed. So here, as the number of units N increases the length of the spike train increases at the same rate. But the density of spikes per time unit stays constant. Both algorithms grow as N^2, as expected (since units and train length are both increasing with N). Again, the old algorithm changes tactic but the new code is still significantly faster.

benchmarch_sync_N_sq

The code for both is below:

times_by_size=[]
sizes = []

how_many = 100

duration = 10**1.7
# duration = 10**1.5 for the first plot
nums_units = np.arange(100,1201,100)

for num_units in nums_units:

    recording, sorting = generate_ground_truth_recording(
            durations=[
               # duration # for the first plot
               # duration/num_units*nums_units[0] # for the second plot
            ],
            sampling_frequency=30_000.0,
            num_channels=6,
            num_units=num_units,
            generate_sorting_kwargs=dict(firing_rates=10.0, refractory_period_ms=4.0),
            noise_kwargs=dict(noise_level=5.0, strategy="tile_pregenerated"),
            seed=2205,
        )

    sorting_analyzer = create_sorting_analyzer(sorting, recording, format="memory", sparse=False)

    times_list = []
    for _ in range(0,how_many):

        start = perf_counter()
        compute_synchrony_metrics(sorting_analyzer)        
        end = perf_counter()

        times_list.append(end - start)

    times_by_size.append( np.median( times_list ) )
    sizes.append( len(sorting_analyzer.sorting.to_spike_vector()) )

Copy link
Collaborator

@h-mayorquin h-mayorquin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey, thanks for removing the change of the output and doing benchmark with units.

I sitll would like to keep the function for generating spikes as simple as possible so I request a removal or the arguments. As I mentioned before:

The thing that I really feel strongly about is for not changing the signature and output of synthesize_poisson_spike_vector.

Unfortunately, I can't review the synchrony metrics changes as I am not really well acquainted with the method. Maybe @DradeAW or @zm711 can take a look?

src/spikeinterface/core/generate.py Outdated Show resolved Hide resolved
----------
spikes : memmap
The spike train
synchrony_sizes : numpy array
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was talking about the synchrony_sizes

src/spikeinterface/core/generate.py Outdated Show resolved Hide resolved
@zm711
Copy link
Collaborator

zm711 commented Mar 26, 2024

Unfortunately, I can't review the synchrony metrics changes as I am not really well acquainted with the method. Maybe @DradeAW or @zm711 can take a look?

@alejoe91 actually ported this from Elephant so he would be the best to review unless @DradeAW knows this super well. I never actually use this metric :)

@chrishalcrow
Copy link
Collaborator Author

Unfortunately, I can't review the synchrony metrics changes as I am not really well acquainted with the method. Maybe @DradeAW or @zm711 can take a look?

@alejoe91 actually ported this from Elephant so he would be the best to review unless @DradeAW knows this super well. I never actually use this metric :)

An important part of the update are the four test_synchrony_counts_blah in test_metrics_functions.py . These are correctness tests for the main part of the algorithm. I make a small spike_train with certain spikes (e.g. two spikes which fire at the same time) and make sure the metric gives the right answer. If anyone can think of any good edge cases to test here, please comment!

Copy link
Collaborator

@h-mayorquin h-mayorquin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for bearing with me and for adding thorough testing.

@h-mayorquin h-mayorquin dismissed their stale review March 27, 2024 17:45

checking if this removes the changes requested tag

def _add_spikes_to_spiketrain(
spike_indices,
spike_labels,
segment_indices=[],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
segment_indices=[],
segment_indices=None,

See #2345

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To quote Joe: "Thanks I did not know about that, I am learning so much working on the SI codebase 😍!"

segment_indices=[],
added_spikes_indices=None,
added_spikes_labels=None,
added_segment_indices=[],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
added_segment_indices=[],
added_segment_indices=None,

@chrishalcrow
Copy link
Collaborator Author

Hello, realised that the _add_spikes_to_spiketrain function was basically just checking a bunch of array lengths then concatenating some arrays together, that were then used for testing. So it wasn't really adding much, and it was actually much simpler to get rid of it and concatenate in the tests directly. Got rid of 200 lines of code, and simplified the pull request a lot!


synchrony_metrics_dict = {}
for sync_idx, synchrony_size in enumerate(synchrony_sizes_np):
synch_id_metrics_dict = {}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
synch_id_metrics_dict = {}
sync_id_metrics_dict = {}

Can we stick to the same naming? :)

Sorry for being overly annoying @chrishalcrow !!!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can name it anything you want if I can stop working on this ;) Just you wait: my next quality_metrics update is gonna be so careful!

@alejoe91 alejoe91 merged commit fa57fee into SpikeInterface:main Apr 16, 2024
11 checks passed
@samuelgarcia
Copy link
Member

Hi.
When we will have time I would be happy to discuss this function.
The synchrony here if I understand correctly is the exact same timestamp but we could have synchrony with very small lags. A lag can be due to small noise and small time shift.
So my main question is how robut is that mertics regarding theses lags ?

@@ -496,7 +496,51 @@ def compute_sliding_rp_violations(
)


def compute_synchrony_metrics(sorting_analyzer, synchrony_sizes=(2, 4, 8), unit_ids=None, **kwargs):
def get_synchrony_counts(spikes, synchrony_sizes, all_unit_ids):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would excpet a delta in the signature no ?

This code was adapted from `Elephant - Electrophysiology Analysis Toolkit <https://github.com/NeuralEnsemble/elephant/blob/master/elephant/spike_train_synchrony.py#L245>`_
"""

synchrony_counts = np.zeros((np.size(synchrony_sizes), len(all_unit_ids)), dtype=np.int64)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this the synhcony is higly dependant on the sampling rate no ?

@chrishalcrow
Copy link
Collaborator Author

Hi. When we will have time I would be happy to discuss this function. The synchrony here if I understand correctly is the exact same timestamp but we could have synchrony with very small lags. A lag can be due to small noise and small time shift. So my main question is how robut is that mertics regarding theses lags ?

Hello, indeed the metric is not robust to lags at all. This PR was just optimizing what already existed. I'm happy to add a delta, which would check if there were spikes within delta samples of the first one?

@alejoe91
Copy link
Member

Let's keep it like this for now, since this was the original design. :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
performance Performance issues/improvements qualitymetrics Related to qualitymetrics module refactor Refactor of code, with no change to functionality
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants