From 6527c9038c53e68c9355f32bd032ecc6824e59ca Mon Sep 17 00:00:00 2001 From: zm711 <92116279+zm711@users.noreply.github.com> Date: Tue, 22 Oct 2024 15:36:44 -0400 Subject: [PATCH 01/18] fix dtype of quality metrics after merging --- .../quality_metric_calculator.py | 6 ++++ .../tests/test_quality_metric_calculator.py | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/spikeinterface/qualitymetrics/quality_metric_calculator.py b/src/spikeinterface/qualitymetrics/quality_metric_calculator.py index b6a50d60f5..c16241710a 100644 --- a/src/spikeinterface/qualitymetrics/quality_metric_calculator.py +++ b/src/spikeinterface/qualitymetrics/quality_metric_calculator.py @@ -125,7 +125,13 @@ def _merge_extension_data( all_unit_ids = new_sorting_analyzer.unit_ids not_new_ids = all_unit_ids[~np.isin(all_unit_ids, new_unit_ids)] + # this creates a new metrics dictionary, but the dtype for everything will be + # object metrics = pd.DataFrame(index=all_unit_ids, columns=old_metrics.columns) + # we can iterate through the columns and convert them back to numbers with + # pandas.to_numeric. coerce allows us to keep the nan values. + for column in metrics.columns: + metrics[column] = pd.to_numeric(metrics[column], errors="coerce") metrics.loc[not_new_ids, :] = old_metrics.loc[not_new_ids, :] metrics.loc[new_unit_ids, :] = self._compute_metrics( diff --git a/src/spikeinterface/qualitymetrics/tests/test_quality_metric_calculator.py b/src/spikeinterface/qualitymetrics/tests/test_quality_metric_calculator.py index a6415c58e8..a34324da7e 100644 --- a/src/spikeinterface/qualitymetrics/tests/test_quality_metric_calculator.py +++ b/src/spikeinterface/qualitymetrics/tests/test_quality_metric_calculator.py @@ -48,6 +48,34 @@ def test_compute_quality_metrics(sorting_analyzer_simple): assert "isolation_distance" in metrics.columns +def test_merging_quality_metrics(sorting_analyzer_simple): + + sorting_analyzer = sorting_analyzer_simple + + metrics = compute_quality_metrics( + sorting_analyzer, + metric_names=None, + qm_params=dict(isi_violation=dict(isi_threshold_ms=2)), + skip_pc_metrics=False, + seed=2205, + ) + + # sorting_analyzer_simple has ten units + new_sorting_analyzer = sorting_analyzer.merge([0, 1]) + + new_metrics = new_sorting_analyzer.get_extension("quality_metrics").get_data() + + # we should copy over the metrics after merge + for column in metrics.columns: + assert column in new_metrics.columns + + # 10 units vs 9 units + assert len(metrics.index) > len(new_metrics.index) + + # dtype should be fine after merge + assert metrics["snr"].dtype == new_metrics["snr"].dtype + + def test_compute_quality_metrics_recordingless(sorting_analyzer_simple): sorting_analyzer = sorting_analyzer_simple From 1e596f87be8e56a3abe1d6071b93adc6bbb50c07 Mon Sep 17 00:00:00 2001 From: zm711 <92116279+zm711@users.noreply.github.com> Date: Tue, 22 Oct 2024 16:00:04 -0400 Subject: [PATCH 02/18] fix test --- .../qualitymetrics/tests/test_quality_metric_calculator.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/spikeinterface/qualitymetrics/tests/test_quality_metric_calculator.py b/src/spikeinterface/qualitymetrics/tests/test_quality_metric_calculator.py index a34324da7e..f4d37aafee 100644 --- a/src/spikeinterface/qualitymetrics/tests/test_quality_metric_calculator.py +++ b/src/spikeinterface/qualitymetrics/tests/test_quality_metric_calculator.py @@ -61,7 +61,7 @@ def test_merging_quality_metrics(sorting_analyzer_simple): ) # sorting_analyzer_simple has ten units - new_sorting_analyzer = sorting_analyzer.merge([0, 1]) + new_sorting_analyzer = sorting_analyzer.merge_units([[0, 1]]) new_metrics = new_sorting_analyzer.get_extension("quality_metrics").get_data() @@ -72,8 +72,8 @@ def test_merging_quality_metrics(sorting_analyzer_simple): # 10 units vs 9 units assert len(metrics.index) > len(new_metrics.index) - # dtype should be fine after merge - assert metrics["snr"].dtype == new_metrics["snr"].dtype + # dtype should be fine after merge but is cast from Float64->float64 + assert np.float64 == new_metrics["snr"].dtype def test_compute_quality_metrics_recordingless(sorting_analyzer_simple): From 812376ee39a74e9c1a158e4aea2c96ee8885cc42 Mon Sep 17 00:00:00 2001 From: Zach McKenzie <92116279+zm711@users.noreply.github.com> Date: Mon, 4 Nov 2024 15:19:28 -0500 Subject: [PATCH 03/18] Alessio's idea Co-authored-by: Alessio Buccino --- src/spikeinterface/qualitymetrics/quality_metric_calculator.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/spikeinterface/qualitymetrics/quality_metric_calculator.py b/src/spikeinterface/qualitymetrics/quality_metric_calculator.py index c16241710a..24ac5fa390 100644 --- a/src/spikeinterface/qualitymetrics/quality_metric_calculator.py +++ b/src/spikeinterface/qualitymetrics/quality_metric_calculator.py @@ -132,6 +132,8 @@ def _merge_extension_data( # pandas.to_numeric. coerce allows us to keep the nan values. for column in metrics.columns: metrics[column] = pd.to_numeric(metrics[column], errors="coerce") + if np.all(np.mod(metrics[column], 1) == 0): + metrics[column] = metrics[column].astype(int) metrics.loc[not_new_ids, :] = old_metrics.loc[not_new_ids, :] metrics.loc[new_unit_ids, :] = self._compute_metrics( From 4a6b1e38c04c827cae91ecd43732b3e4aaed906d Mon Sep 17 00:00:00 2001 From: zm711 <92116279+zm711@users.noreply.github.com> Date: Mon, 4 Nov 2024 15:23:21 -0500 Subject: [PATCH 04/18] add int test --- .../qualitymetrics/tests/test_quality_metric_calculator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/spikeinterface/qualitymetrics/tests/test_quality_metric_calculator.py b/src/spikeinterface/qualitymetrics/tests/test_quality_metric_calculator.py index f4d37aafee..33cb84c5ed 100644 --- a/src/spikeinterface/qualitymetrics/tests/test_quality_metric_calculator.py +++ b/src/spikeinterface/qualitymetrics/tests/test_quality_metric_calculator.py @@ -75,6 +75,9 @@ def test_merging_quality_metrics(sorting_analyzer_simple): # dtype should be fine after merge but is cast from Float64->float64 assert np.float64 == new_metrics["snr"].dtype + # test that we appropriate convert int based metrics to int + assert np.int32 == new_metrics['num_spikes'].dtype + def test_compute_quality_metrics_recordingless(sorting_analyzer_simple): From 1f2e2f1d803b8150c15432c8eb1ce3757641e1a2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 4 Nov 2024 20:24:16 +0000 Subject: [PATCH 05/18] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../qualitymetrics/tests/test_quality_metric_calculator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/qualitymetrics/tests/test_quality_metric_calculator.py b/src/spikeinterface/qualitymetrics/tests/test_quality_metric_calculator.py index 33cb84c5ed..cff8d88cca 100644 --- a/src/spikeinterface/qualitymetrics/tests/test_quality_metric_calculator.py +++ b/src/spikeinterface/qualitymetrics/tests/test_quality_metric_calculator.py @@ -76,7 +76,7 @@ def test_merging_quality_metrics(sorting_analyzer_simple): assert np.float64 == new_metrics["snr"].dtype # test that we appropriate convert int based metrics to int - assert np.int32 == new_metrics['num_spikes'].dtype + assert np.int32 == new_metrics["num_spikes"].dtype def test_compute_quality_metrics_recordingless(sorting_analyzer_simple): From 0cc1fa1a09ffaa0d3f74e4fd301a21a8d807a8e4 Mon Sep 17 00:00:00 2001 From: zm711 <92116279+zm711@users.noreply.github.com> Date: Mon, 4 Nov 2024 15:54:52 -0500 Subject: [PATCH 06/18] try different dtype approach --- .../qualitymetrics/quality_metric_calculator.py | 15 +++++++-------- .../tests/test_quality_metric_calculator.py | 8 ++------ 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/src/spikeinterface/qualitymetrics/quality_metric_calculator.py b/src/spikeinterface/qualitymetrics/quality_metric_calculator.py index 24ac5fa390..bcea6ab612 100644 --- a/src/spikeinterface/qualitymetrics/quality_metric_calculator.py +++ b/src/spikeinterface/qualitymetrics/quality_metric_calculator.py @@ -126,20 +126,19 @@ def _merge_extension_data( not_new_ids = all_unit_ids[~np.isin(all_unit_ids, new_unit_ids)] # this creates a new metrics dictionary, but the dtype for everything will be - # object + # object. So we will need to fix this later after computing metrics metrics = pd.DataFrame(index=all_unit_ids, columns=old_metrics.columns) - # we can iterate through the columns and convert them back to numbers with - # pandas.to_numeric. coerce allows us to keep the nan values. - for column in metrics.columns: - metrics[column] = pd.to_numeric(metrics[column], errors="coerce") - if np.all(np.mod(metrics[column], 1) == 0): - metrics[column] = metrics[column].astype(int) - metrics.loc[not_new_ids, :] = old_metrics.loc[not_new_ids, :] metrics.loc[new_unit_ids, :] = self._compute_metrics( new_sorting_analyzer, new_unit_ids, verbose, metric_names, **job_kwargs ) + # we need to fix the dtypes after we compute everything because we have nans + # we can iterate through the columns and convert them back to the dtype + # of the original quality dataframe. + for column in old_metrics.columns: + metrics[column] = metrics[column].astype(old_metrics[column].dtype) + new_data = dict(metrics=metrics) return new_data diff --git a/src/spikeinterface/qualitymetrics/tests/test_quality_metric_calculator.py b/src/spikeinterface/qualitymetrics/tests/test_quality_metric_calculator.py index cff8d88cca..c4c1778cf2 100644 --- a/src/spikeinterface/qualitymetrics/tests/test_quality_metric_calculator.py +++ b/src/spikeinterface/qualitymetrics/tests/test_quality_metric_calculator.py @@ -68,16 +68,12 @@ def test_merging_quality_metrics(sorting_analyzer_simple): # we should copy over the metrics after merge for column in metrics.columns: assert column in new_metrics.columns + # should copy dtype too + assert metrics[column].dtype == new_metrics[column].dtype # 10 units vs 9 units assert len(metrics.index) > len(new_metrics.index) - # dtype should be fine after merge but is cast from Float64->float64 - assert np.float64 == new_metrics["snr"].dtype - - # test that we appropriate convert int based metrics to int - assert np.int32 == new_metrics["num_spikes"].dtype - def test_compute_quality_metrics_recordingless(sorting_analyzer_simple): From e175bdc0323d4d0e4d6c7213bb5d27ee92b4febb Mon Sep 17 00:00:00 2001 From: zm711 <92116279+zm711@users.noreply.github.com> Date: Fri, 22 Nov 2024 16:06:19 -0500 Subject: [PATCH 07/18] wip --- .../quality_metric_calculator.py | 7 ++++ .../qualitymetrics/quality_metric_list.py | 38 ++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/spikeinterface/qualitymetrics/quality_metric_calculator.py b/src/spikeinterface/qualitymetrics/quality_metric_calculator.py index bcea6ab612..aef3631438 100644 --- a/src/spikeinterface/qualitymetrics/quality_metric_calculator.py +++ b/src/spikeinterface/qualitymetrics/quality_metric_calculator.py @@ -16,6 +16,7 @@ _misc_metric_name_to_func, _possible_pc_metric_names, compute_name_to_column_names, + column_name_to_column_dtype, ) from .misc_metrics import _default_params as misc_metrics_params from .pca_metrics import _default_params as pca_metrics_params @@ -225,6 +226,12 @@ def _compute_metrics(self, sorting_analyzer, unit_ids=None, verbose=False, metri # we use the convert_dtypes to convert the columns to the most appropriate dtype and avoid object columns # (in case of NaN values) metrics = metrics.convert_dtypes() + + # we do this because the convert_dtypes infers the wrong types sometimes. + # the actual types for columns can be found in column_name_to_column_dtype dictionary. + for column in metrics.columns: + metrics[column] = metrics[column].astype(column_name_to_column_dtype[column]) + return metrics def _run(self, verbose=False, **job_kwargs): diff --git a/src/spikeinterface/qualitymetrics/quality_metric_list.py b/src/spikeinterface/qualitymetrics/quality_metric_list.py index 375dd320ae..8ad3bee44c 100644 --- a/src/spikeinterface/qualitymetrics/quality_metric_list.py +++ b/src/spikeinterface/qualitymetrics/quality_metric_list.py @@ -66,7 +66,11 @@ "amplitude_cutoff": ["amplitude_cutoff"], "amplitude_median": ["amplitude_median"], "amplitude_cv": ["amplitude_cv_median", "amplitude_cv_range"], - "synchrony": ["sync_spike_2", "sync_spike_4", "sync_spike_8"], + "synchrony": [ + "sync_spike_2", + "sync_spike_4", + "sync_spike_8", + ], # we probably shouldn't hard code this. This is determined by the arguments in the function... "firing_range": ["firing_range"], "drift": ["drift_ptp", "drift_std", "drift_mad"], "sd_ratio": ["sd_ratio"], @@ -79,3 +83,35 @@ "silhouette": ["silhouette"], "silhouette_full": ["silhouette_full"], } + +column_name_to_column_dtype = { + "num_spikes": int, + "firing_rate": float, + "presence_ratio": float, + "snr": float, + "isi_violations_ratio": float, + "isi_violations_count": float, + "rp_violations": float, + "rp_contamination": float, + "sliding_rp_violation": float, + "amplitude_cutoff": float, + "amplitude_median": float, + "amplitude_cv_median": float, + "amplitude_cv_range": float, + "synch": float, + "firing_range": float, + "drift_ptp": float, + "drift_std": float, + "drift_mad": float, + "sd_ratio": float, + "isolation_distance": float, + "l_ratio": float, + "d_prime": float, + "nn_hit_rate": float, + "nn_miss_rate": float, + "nn_isolation": float, + "nn_unit_id": float, + "nn_noise_overlap": float, + "silhouette": float, + "silhouette_full": float, +} From bf96fe114b9e479a3db784f4e4de2aa02f65489e Mon Sep 17 00:00:00 2001 From: zm711 <92116279+zm711@users.noreply.github.com> Date: Fri, 22 Nov 2024 16:13:20 -0500 Subject: [PATCH 08/18] fix synchrony --- .../qualitymetrics/quality_metric_calculator.py | 8 +++++++- src/spikeinterface/qualitymetrics/quality_metric_list.py | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/spikeinterface/qualitymetrics/quality_metric_calculator.py b/src/spikeinterface/qualitymetrics/quality_metric_calculator.py index aef3631438..5cefcaa75d 100644 --- a/src/spikeinterface/qualitymetrics/quality_metric_calculator.py +++ b/src/spikeinterface/qualitymetrics/quality_metric_calculator.py @@ -230,7 +230,13 @@ def _compute_metrics(self, sorting_analyzer, unit_ids=None, verbose=False, metri # we do this because the convert_dtypes infers the wrong types sometimes. # the actual types for columns can be found in column_name_to_column_dtype dictionary. for column in metrics.columns: - metrics[column] = metrics[column].astype(column_name_to_column_dtype[column]) + # we have one issue where the name of the columns for synchrony are named based on + # what the user has input as arguments so we need a way to handle this separately + # everything else should be handled with the column name. + if "sync" in column: + metrics[column] = metrics[column].astype(column_name_to_column_dtype["sync"]) + else: + metrics[column] = metrics[column].astype(column_name_to_column_dtype[column]) return metrics diff --git a/src/spikeinterface/qualitymetrics/quality_metric_list.py b/src/spikeinterface/qualitymetrics/quality_metric_list.py index 8ad3bee44c..685aaddc83 100644 --- a/src/spikeinterface/qualitymetrics/quality_metric_list.py +++ b/src/spikeinterface/qualitymetrics/quality_metric_list.py @@ -84,6 +84,7 @@ "silhouette_full": ["silhouette_full"], } +# this dict allows us to ensure the appropriate dtype of metrics rather than allow Pandas to infer them column_name_to_column_dtype = { "num_spikes": int, "firing_rate": float, @@ -98,7 +99,7 @@ "amplitude_median": float, "amplitude_cv_median": float, "amplitude_cv_range": float, - "synch": float, + "sync": float, "firing_range": float, "drift_ptp": float, "drift_std": float, From 5b77ba170788c18fcb9fb06413b8baf58caf73fb Mon Sep 17 00:00:00 2001 From: zm711 <92116279+zm711@users.noreply.github.com> Date: Fri, 22 Nov 2024 16:29:44 -0500 Subject: [PATCH 09/18] fix nan and empty units --- .../qualitymetrics/quality_metric_calculator.py | 3 +++ .../qualitymetrics/tests/test_quality_metric_calculator.py | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/spikeinterface/qualitymetrics/quality_metric_calculator.py b/src/spikeinterface/qualitymetrics/quality_metric_calculator.py index 5cefcaa75d..6fdc21bac2 100644 --- a/src/spikeinterface/qualitymetrics/quality_metric_calculator.py +++ b/src/spikeinterface/qualitymetrics/quality_metric_calculator.py @@ -222,6 +222,9 @@ def _compute_metrics(self, sorting_analyzer, unit_ids=None, verbose=False, metri # add NaN for empty units if len(empty_unit_ids) > 0: metrics.loc[empty_unit_ids] = np.nan + # num_spikes is an int and should be 0 + if "num_spikes" in metrics.columns: + metrics.loc[empty_unit_ids, ["num_spikes"]] = 0 # we use the convert_dtypes to convert the columns to the most appropriate dtype and avoid object columns # (in case of NaN values) diff --git a/src/spikeinterface/qualitymetrics/tests/test_quality_metric_calculator.py b/src/spikeinterface/qualitymetrics/tests/test_quality_metric_calculator.py index c4c1778cf2..56e3975210 100644 --- a/src/spikeinterface/qualitymetrics/tests/test_quality_metric_calculator.py +++ b/src/spikeinterface/qualitymetrics/tests/test_quality_metric_calculator.py @@ -133,10 +133,15 @@ def test_empty_units(sorting_analyzer_simple): seed=2205, ) + # num_spikes are ints not nans so we confirm empty units are nans for everything except + # num_spikes which should be 0 + nan_containing_columns = [column for column in metrics_empty.columns if column != "num_spikes"] for empty_unit_id in sorting_empty.get_empty_unit_ids(): from pandas import isnull - assert np.all(isnull(metrics_empty.loc[empty_unit_id].values)) + assert np.all(isnull(metrics_empty.loc[empty_unit_id, nan_containing_columns].values)) + if "num_spikes" in metrics_empty.columns: + assert metrics_empty.loc[empty_unit_id, ["num_spikes"]] == 0 # TODO @alessio all theses old test should be moved in test_metric_functions.py or test_pca_metrics() From 807e771dfb9a2f6041a12f10baf28ac17ad0c0a0 Mon Sep 17 00:00:00 2001 From: zm711 <92116279+zm711@users.noreply.github.com> Date: Fri, 22 Nov 2024 16:38:17 -0500 Subject: [PATCH 10/18] fix test --- .../qualitymetrics/tests/test_quality_metric_calculator.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/spikeinterface/qualitymetrics/tests/test_quality_metric_calculator.py b/src/spikeinterface/qualitymetrics/tests/test_quality_metric_calculator.py index 56e3975210..71569e7b2b 100644 --- a/src/spikeinterface/qualitymetrics/tests/test_quality_metric_calculator.py +++ b/src/spikeinterface/qualitymetrics/tests/test_quality_metric_calculator.py @@ -136,12 +136,12 @@ def test_empty_units(sorting_analyzer_simple): # num_spikes are ints not nans so we confirm empty units are nans for everything except # num_spikes which should be 0 nan_containing_columns = [column for column in metrics_empty.columns if column != "num_spikes"] - for empty_unit_id in sorting_empty.get_empty_unit_ids(): + for empty_unit_ids in sorting_empty.get_empty_unit_ids(): from pandas import isnull - assert np.all(isnull(metrics_empty.loc[empty_unit_id, nan_containing_columns].values)) + assert np.all(isnull(metrics_empty.loc[empty_unit_ids, nan_containing_columns].values)) if "num_spikes" in metrics_empty.columns: - assert metrics_empty.loc[empty_unit_id, ["num_spikes"]] == 0 + assert sum(metrics_empty.loc[empty_unit_ids, ["num_spikes"]]) == 0 # TODO @alessio all theses old test should be moved in test_metric_functions.py or test_pca_metrics() From e74aa00e2c8ee5d6e94f79da491a565fcef322c8 Mon Sep 17 00:00:00 2001 From: chrishalcrow <57948917+chrishalcrow@users.noreply.github.com> Date: Wed, 8 Jan 2025 15:21:52 +0000 Subject: [PATCH 11/18] string-ify unit_ids in plot_2_sort_gallery --- examples/tutorials/widgets/plot_2_sort_gallery.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/tutorials/widgets/plot_2_sort_gallery.py b/examples/tutorials/widgets/plot_2_sort_gallery.py index da5c611ce4..056b5e3a8d 100644 --- a/examples/tutorials/widgets/plot_2_sort_gallery.py +++ b/examples/tutorials/widgets/plot_2_sort_gallery.py @@ -31,14 +31,14 @@ # plot_autocorrelograms() # ~~~~~~~~~~~~~~~~~~~~~~~~ -w_ach = sw.plot_autocorrelograms(sorting, window_ms=150.0, bin_ms=5.0, unit_ids=[1, 2, 5]) +w_ach = sw.plot_autocorrelograms(sorting, window_ms=150.0, bin_ms=5.0, unit_ids=['1', '2', '5']) ############################################################################## # plot_crosscorrelograms() # ~~~~~~~~~~~~~~~~~~~~~~~~ -w_cch = sw.plot_crosscorrelograms(sorting, window_ms=150.0, bin_ms=5.0, unit_ids=[1, 2, 5]) +w_cch = sw.plot_crosscorrelograms(sorting, window_ms=150.0, bin_ms=5.0, unit_ids=['1', '2', '5']) plt.show() From ac46fb5d4c88ba9dcaa8370dc937882cf6e473c5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 8 Jan 2025 21:36:58 +0000 Subject: [PATCH 12/18] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/spikeinterface/qualitymetrics/quality_metric_calculator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/spikeinterface/qualitymetrics/quality_metric_calculator.py b/src/spikeinterface/qualitymetrics/quality_metric_calculator.py index 1f79acaa8b..6410647371 100644 --- a/src/spikeinterface/qualitymetrics/quality_metric_calculator.py +++ b/src/spikeinterface/qualitymetrics/quality_metric_calculator.py @@ -18,7 +18,6 @@ _possible_pc_metric_names, compute_name_to_column_names, column_name_to_column_dtype, - ) from .misc_metrics import _default_params as misc_metrics_params from .pca_metrics import _default_params as pca_metrics_params From e1c401dd62d014d0f69996e9592b708b8eb0460f Mon Sep 17 00:00:00 2001 From: Zach McKenzie <92116279+zm711@users.noreply.github.com> Date: Wed, 8 Jan 2025 16:39:24 -0500 Subject: [PATCH 13/18] oops --- src/spikeinterface/qualitymetrics/quality_metric_calculator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/spikeinterface/qualitymetrics/quality_metric_calculator.py b/src/spikeinterface/qualitymetrics/quality_metric_calculator.py index 6410647371..4aad25e928 100644 --- a/src/spikeinterface/qualitymetrics/quality_metric_calculator.py +++ b/src/spikeinterface/qualitymetrics/quality_metric_calculator.py @@ -16,7 +16,6 @@ compute_pc_metrics, _misc_metric_name_to_func, _possible_pc_metric_names, - compute_name_to_column_names, column_name_to_column_dtype, ) from .misc_metrics import _default_params as misc_metrics_params From cdc1b2a34f8f0fc6a15a7b6a573c8ed8ce4c2a77 Mon Sep 17 00:00:00 2001 From: zm711 <92116279+zm711@users.noreply.github.com> Date: Thu, 9 Jan 2025 12:12:32 -0500 Subject: [PATCH 14/18] add back in list --- src/spikeinterface/qualitymetrics/quality_metric_calculator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/spikeinterface/qualitymetrics/quality_metric_calculator.py b/src/spikeinterface/qualitymetrics/quality_metric_calculator.py index 4aad25e928..834c8a9974 100644 --- a/src/spikeinterface/qualitymetrics/quality_metric_calculator.py +++ b/src/spikeinterface/qualitymetrics/quality_metric_calculator.py @@ -16,6 +16,7 @@ compute_pc_metrics, _misc_metric_name_to_func, _possible_pc_metric_names, + qm_compute_name_to_column_names, column_name_to_column_dtype, ) from .misc_metrics import _default_params as misc_metrics_params From e45a9f85d49474d625bb4728b971b8357368e643 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Fri, 10 Jan 2025 11:52:40 +0100 Subject: [PATCH 15/18] Exploit hard-coded sync sizes --- .../qualitymetrics/quality_metric_calculator.py | 5 +---- src/spikeinterface/qualitymetrics/quality_metric_list.py | 6 ++++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/spikeinterface/qualitymetrics/quality_metric_calculator.py b/src/spikeinterface/qualitymetrics/quality_metric_calculator.py index 834c8a9974..02409ffdbb 100644 --- a/src/spikeinterface/qualitymetrics/quality_metric_calculator.py +++ b/src/spikeinterface/qualitymetrics/quality_metric_calculator.py @@ -251,10 +251,7 @@ def _compute_metrics(self, sorting_analyzer, unit_ids=None, verbose=False, metri # we have one issue where the name of the columns for synchrony are named based on # what the user has input as arguments so we need a way to handle this separately # everything else should be handled with the column name. - if "sync" in column: - metrics[column] = metrics[column].astype(column_name_to_column_dtype["sync"]) - else: - metrics[column] = metrics[column].astype(column_name_to_column_dtype[column]) + metrics[column] = metrics[column].astype(column_name_to_column_dtype[column]) return metrics diff --git a/src/spikeinterface/qualitymetrics/quality_metric_list.py b/src/spikeinterface/qualitymetrics/quality_metric_list.py index fc7ae906e7..23b781eb9d 100644 --- a/src/spikeinterface/qualitymetrics/quality_metric_list.py +++ b/src/spikeinterface/qualitymetrics/quality_metric_list.py @@ -70,7 +70,7 @@ "sync_spike_2", "sync_spike_4", "sync_spike_8", - ], # we probably shouldn't hard code this. This is determined by the arguments in the function... + ], "firing_range": ["firing_range"], "drift": ["drift_ptp", "drift_std", "drift_mad"], "sd_ratio": ["sd_ratio"], @@ -99,7 +99,9 @@ "amplitude_median": float, "amplitude_cv_median": float, "amplitude_cv_range": float, - "sync": float, + "sync_spike_2": float, + "sync_spike_4": float, + "sync_spike_8": float, "firing_range": float, "drift_ptp": float, "drift_std": float, From 8353160e7804dff75bcb4dde07c07ef4650d7244 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Fri, 10 Jan 2025 11:53:48 +0100 Subject: [PATCH 16/18] Remove comment --- src/spikeinterface/qualitymetrics/quality_metric_calculator.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/spikeinterface/qualitymetrics/quality_metric_calculator.py b/src/spikeinterface/qualitymetrics/quality_metric_calculator.py index 02409ffdbb..2f92f50ef0 100644 --- a/src/spikeinterface/qualitymetrics/quality_metric_calculator.py +++ b/src/spikeinterface/qualitymetrics/quality_metric_calculator.py @@ -248,9 +248,6 @@ def _compute_metrics(self, sorting_analyzer, unit_ids=None, verbose=False, metri # we do this because the convert_dtypes infers the wrong types sometimes. # the actual types for columns can be found in column_name_to_column_dtype dictionary. for column in metrics.columns: - # we have one issue where the name of the columns for synchrony are named based on - # what the user has input as arguments so we need a way to handle this separately - # everything else should be handled with the column name. metrics[column] = metrics[column].astype(column_name_to_column_dtype[column]) return metrics From 33feca3a65416ba8c214e8a384e24a55aec1bd5c Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Fri, 10 Jan 2025 11:58:17 +0100 Subject: [PATCH 17/18] Protect dtype casting only when column is in column_name_to_column_dtype --- src/spikeinterface/qualitymetrics/quality_metric_calculator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/spikeinterface/qualitymetrics/quality_metric_calculator.py b/src/spikeinterface/qualitymetrics/quality_metric_calculator.py index 2f92f50ef0..11ce3d0160 100644 --- a/src/spikeinterface/qualitymetrics/quality_metric_calculator.py +++ b/src/spikeinterface/qualitymetrics/quality_metric_calculator.py @@ -248,7 +248,8 @@ def _compute_metrics(self, sorting_analyzer, unit_ids=None, verbose=False, metri # we do this because the convert_dtypes infers the wrong types sometimes. # the actual types for columns can be found in column_name_to_column_dtype dictionary. for column in metrics.columns: - metrics[column] = metrics[column].astype(column_name_to_column_dtype[column]) + if column in column_name_to_column_dtype: + metrics[column] = metrics[column].astype(column_name_to_column_dtype[column]) return metrics From 9ff3070d6bc59a50645df6b22336dd8a03c22c86 Mon Sep 17 00:00:00 2001 From: Anoushka Jain Date: Fri, 10 Jan 2025 15:48:53 +0100 Subject: [PATCH 18/18] Automatic curation with metrics (#2918) Co-authored-by: Robyn Greene Co-authored-by: jakeswann1 Co-authored-by: Jake Swann <66915197+jakeswann1@users.noreply.github.com> Co-authored-by: Chris Halcrow <57948917+chrishalcrow@users.noreply.github.com> Co-authored-by: Alessio Buccino --- doc/api.rst | 3 + doc/conf.py | 1 + doc/how_to/auto_curation_prediction.rst | 43 + doc/how_to/auto_curation_training.rst | 58 ++ doc/how_to/index.rst | 2 + doc/images/files_screen.png | Bin 0 -> 99254 bytes doc/images/hf-logo.svg | 8 + doc/images/initial_model_screen.png | Bin 0 -> 34596 bytes doc/tutorials_custom_index.rst | 37 +- examples/tutorials/curation/README.rst | 5 + .../curation/plot_1_automated_curation.py | 287 ++++++ .../curation/plot_2_train_a_model.py | 168 ++++ .../curation/plot_3_upload_a_model.py | 139 +++ ...y_mertics.py => plot_3_quality_metrics.py} | 0 pyproject.toml | 8 + src/spikeinterface/curation/__init__.py | 4 + .../curation/model_based_curation.py | 435 +++++++++ .../tests/test_model_based_curation.py | 167 ++++ .../tests/test_train_manual_curation.py | 285 ++++++ .../tests/trained_pipeline/best_model.skops | Bin 0 -> 34009 bytes .../tests/trained_pipeline/labels.csv | 21 + .../trained_pipeline/model_accuracies.csv | 2 + .../tests/trained_pipeline/model_info.json | 60 ++ .../tests/trained_pipeline/training_data.csv | 21 + .../curation/train_manual_curation.py | 843 ++++++++++++++++++ .../qualitymetrics/pca_metrics.py | 3 + 26 files changed, 2598 insertions(+), 2 deletions(-) create mode 100644 doc/how_to/auto_curation_prediction.rst create mode 100644 doc/how_to/auto_curation_training.rst create mode 100644 doc/images/files_screen.png create mode 100644 doc/images/hf-logo.svg create mode 100644 doc/images/initial_model_screen.png create mode 100644 examples/tutorials/curation/README.rst create mode 100644 examples/tutorials/curation/plot_1_automated_curation.py create mode 100644 examples/tutorials/curation/plot_2_train_a_model.py create mode 100644 examples/tutorials/curation/plot_3_upload_a_model.py rename examples/tutorials/qualitymetrics/{plot_3_quality_mertics.py => plot_3_quality_metrics.py} (100%) create mode 100644 src/spikeinterface/curation/model_based_curation.py create mode 100644 src/spikeinterface/curation/tests/test_model_based_curation.py create mode 100644 src/spikeinterface/curation/tests/test_train_manual_curation.py create mode 100644 src/spikeinterface/curation/tests/trained_pipeline/best_model.skops create mode 100644 src/spikeinterface/curation/tests/trained_pipeline/labels.csv create mode 100644 src/spikeinterface/curation/tests/trained_pipeline/model_accuracies.csv create mode 100644 src/spikeinterface/curation/tests/trained_pipeline/model_info.json create mode 100644 src/spikeinterface/curation/tests/trained_pipeline/training_data.csv create mode 100644 src/spikeinterface/curation/train_manual_curation.py diff --git a/doc/api.rst b/doc/api.rst index 6bb9b39091..eb9a61eb9c 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -346,6 +346,9 @@ spikeinterface.curation .. autofunction:: remove_redundant_units .. autofunction:: remove_duplicated_spikes .. autofunction:: remove_excess_spikes + .. autofunction:: load_model + .. autofunction:: auto_label_units + .. autofunction:: train_model Deprecated ~~~~~~~~~~ diff --git a/doc/conf.py b/doc/conf.py index e3d58ca8f2..41659d2e84 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -125,6 +125,7 @@ 'subsection_order': ExplicitOrder([ '../examples/tutorials/core', '../examples/tutorials/extractors', + '../examples/tutorials/curation', '../examples/tutorials/qualitymetrics', '../examples/tutorials/comparison', '../examples/tutorials/widgets', diff --git a/doc/how_to/auto_curation_prediction.rst b/doc/how_to/auto_curation_prediction.rst new file mode 100644 index 0000000000..9b1612ec12 --- /dev/null +++ b/doc/how_to/auto_curation_prediction.rst @@ -0,0 +1,43 @@ +How to use a trained model to predict the curation labels +========================================================= + +For a more detailed guide to using trained models, `read our tutorial here +`_). + +There is a Collection of models for automated curation available on the +`SpikeInterface HuggingFace page `_. + +We'll apply the model ``toy_tetrode_model`` from ``SpikeInterface`` on a SortingAnalyzer +called ``sorting_analyzer``. We assume that the quality and template metrics have +already been computed. + +We need to pass the ``sorting_analyzer``, the ``repo_id`` (which is just the part of the +repo's URL after huggingface.co/) and that we trust the model. + +.. code:: + + from spikeinterface.curation import auto_label_units + + labels_and_probabilities = auto_label_units( + sorting_analyzer = sorting_analyzer, + repo_id = "SpikeInterface/toy_tetrode_model", + trust_model = True + ) + +If you have a local directory containing the model in a ``skops`` file you can use this to +create the labels: + +.. code:: + + labels_and_probabilities = si.auto_label_units( + sorting_analyzer = sorting_analyzer, + model_folder = "my_folder_with_a_model_in_it", + ) + +The returned labels are a dictionary of model's predictions and it's confidence. These +are also saved as a property of your ``sorting_analyzer`` and can be accessed like so: + +.. code:: + + labels = sorting_analyzer.sorting.get_property("classifier_label") + probabilities = sorting_analyzer.sorting.get_property("classifier_probability") diff --git a/doc/how_to/auto_curation_training.rst b/doc/how_to/auto_curation_training.rst new file mode 100644 index 0000000000..20ab57d284 --- /dev/null +++ b/doc/how_to/auto_curation_training.rst @@ -0,0 +1,58 @@ +How to train a model to predict curation labels +=============================================== + +A full tutorial for model-based curation can be found `here `_. + +Here, we assume that you have: + +* Two SortingAnalyzers called ``analyzer_1`` and + ``analyzer_2``, and have calculated some template and quality metrics for both +* Manually curated labels for the units in each analyzer, in lists called + ``analyzer_1_labels`` and ``analyzer_2_labels``. If you have used phy, the lists can + be accessed using ``curated_labels = analyzer.sorting.get_property("quality")``. + +With these objects calculated, you can train a model as follows + +.. code:: + + from spikeinterface.curation import train_model + + analyzer_list = [analyzer_1, analyzer_2] + labels_list = [analyzer_1_labels, analyzer_2_labels] + output_folder = "/path/to/output_folder" + + trainer = train_model( + mode="analyzers", + labels=labels_list, + analyzers=analyzer_list, + output_folder=output_folder, + metric_names=None, # Set if you want to use a subset of metrics, defaults to all calculated quality and template metrics + imputation_strategies=None, # Default is all available imputation strategies + scaling_techniques=None, # Default is all available scaling techniques + classifiers=None, # Defaults to Random Forest classifier only - we usually find this gives the best results, but a range of classifiers is available + seed=None, # Set a seed for reproducibility + ) + + +The trainer tries several models and chooses the most accurate one. This model and +some metadata are stored in the ``output_folder``, which can later be loaded using the +``load_model`` function (`more details `_). +We can also access the model, which is an sklearn ``Pipeline``, from the trainer object + +.. code:: + + best_model = trainer.best_pipeline + + +The training function can also be run in “csv” mode, if you prefer to +store metrics in as .csv files. If the target labels are stored as a column in +the file, you can point to these with the ``target_label`` parameter + +.. code:: + + trainer = train_model( + mode="csv", + metrics_paths = ["/path/to/csv_file_1", "/path/to/csv_file_2"], + target_label = "my_label", + output_folder=output_folder, + ) diff --git a/doc/how_to/index.rst b/doc/how_to/index.rst index 5d7eae9003..7f79156a3b 100644 --- a/doc/how_to/index.rst +++ b/doc/how_to/index.rst @@ -15,3 +15,5 @@ Guides on how to solve specific, short problems in SpikeInterface. Learn how to. load_your_data_into_sorting benchmark_with_hybrid_recordings drift_with_lfp + auto_curation_training + auto_curation_prediction diff --git a/doc/images/files_screen.png b/doc/images/files_screen.png new file mode 100644 index 0000000000000000000000000000000000000000..ef2b5b08736cded355de3473c31075f2f04430f5 GIT binary patch literal 99254 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 + + + + + + + diff --git a/doc/images/initial_model_screen.png b/doc/images/initial_model_screen.png new file mode 100644 index 0000000000000000000000000000000000000000..b01c4248a6915a5253fbec778477bf3b1498f5cc GIT binary patch literal 34596 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
J_&?e~o~PZdRhK3b+Yv|) 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 S1L$ksU{Yf8qSe9%0sjvo9p`%h literal 0 HcmV?d00001 diff --git a/doc/tutorials_custom_index.rst b/doc/tutorials_custom_index.rst index 4c7625d811..82f2c06eed 100644 --- a/doc/tutorials_custom_index.rst +++ b/doc/tutorials_custom_index.rst @@ -119,8 +119,8 @@ The :code:`spikeinterface.qualitymetrics` module allows users to compute various .. grid-item-card:: Quality Metrics :link-type: ref - :link: sphx_glr_tutorials_qualitymetrics_plot_3_quality_mertics.py - :img-top: /tutorials/qualitymetrics/images/thumb/sphx_glr_plot_3_quality_mertics_thumb.png + :link: sphx_glr_tutorials_qualitymetrics_plot_3_quality_metrics.py + :img-top: /tutorials/qualitymetrics/images/thumb/sphx_glr_plot_3_quality_metrics_thumb.png :img-alt: Quality Metrics :class-card: gallery-card :text-align: center @@ -133,6 +133,39 @@ The :code:`spikeinterface.qualitymetrics` module allows users to compute various :class-card: gallery-card :text-align: center +Automated curation tutorials +---------------------------- + +Learn how to curate your units using a trained machine learning model. Or how to create +and share your own model. + +.. grid:: 1 2 2 3 + :gutter: 2 + + .. grid-item-card:: Model-based curation + :link-type: ref + :link: sphx_glr_tutorials_curation_plot_1_automated_curation.py + :img-top: /tutorials/curation/images/sphx_glr_plot_1_automated_curation_002.png + :img-alt: Model-based curation + :class-card: gallery-card + :text-align: center + + .. grid-item-card:: Train your own model + :link-type: ref + :link: sphx_glr_tutorials_curation_plot_2_train_a_model.py + :img-top: /tutorials/curation/images/thumb/sphx_glr_plot_2_train_a_model_thumb.png + :img-alt: Train your own model + :class-card: gallery-card + :text-align: center + + .. grid-item-card:: Upload your model to HuggingFaceHub + :link-type: ref + :link: sphx_glr_tutorials_curation_plot_3_upload_a_model.py + :img-top: /images/hf-logo.svg + :img-alt: Upload your model + :class-card: gallery-card + :text-align: center + Comparison tutorial ------------------- diff --git a/examples/tutorials/curation/README.rst b/examples/tutorials/curation/README.rst new file mode 100644 index 0000000000..0f64179e65 --- /dev/null +++ b/examples/tutorials/curation/README.rst @@ -0,0 +1,5 @@ +Curation tutorials +------------------ + +Learn how to use models to automatically curated your sorted data, or generate models +based on your own curation. diff --git a/examples/tutorials/curation/plot_1_automated_curation.py b/examples/tutorials/curation/plot_1_automated_curation.py new file mode 100644 index 0000000000..e88b0973df --- /dev/null +++ b/examples/tutorials/curation/plot_1_automated_curation.py @@ -0,0 +1,287 @@ +""" +Model-based curation tutorial +============================= + +Sorters are not perfect. They output excellent units, as well as noisy ones, and ones that +should be split or merged. Hence one should curate the generated units. Historically, this +has been done using laborious manual curation. An alternative is to use automated methods +based on metrics which quantify features of the units. In spikeinterface these are the +quality metrics and the template metrics. A simple approach is to use thresholding: +only accept units whose metrics pass a certain quality threshold. Another approach is to +take one (or more) manually labelled sortings, whose metrics have been computed, and train +a machine learning model to predict labels. + +This notebook provides a step-by-step guide on how to take a machine learning model that +someone else has trained and use it to curate your own spike sorted output. SpikeInterface +also provides the tools to train your own model, +`which you can learn about here `_. + +We'll download a toy model and use it to label our sorted data. We start by importing some packages +""" + +import warnings +warnings.filterwarnings("ignore") +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt + +import spikeinterface.core as si +import spikeinterface.curation as sc +import spikeinterface.widgets as sw + +# note: you can use more cores using e.g. +# si.set_global_jobs_kwargs(n_jobs = 8) + +############################################################################## +# Download a pretrained model +# --------------------------- +# +# Let's download a pretrained model from `Hugging Face `_ (HF), +# a model sharing platform focused on AI and ML models and datasets. The +# ``load_model`` function allows us to download directly from HF, or use a model in a local +# folder. The function downloads the model and saves it in a temporary folder and returns a +# model and some metadata about the model. + +model, model_info = sc.load_model( + repo_id = "SpikeInterface/toy_tetrode_model", + trusted = ['numpy.dtype'] +) + + +############################################################################## +# This model was trained on artifically generated tetrode data. There are also models trained +# on real data, like the one discussed `below <#A-model-trained-on-real-Neuropixels-data>`_. +# Each model object has a nice html representation, which will appear if you're using a Jupyter notebook. + +model + +############################################################################## +# This tells us more information about the model. The one we've just downloaded was trained used +# a ``RandomForestClassifier```. You can also discover this information by running +# ``model.get_params()``. The model object (an `sklearn Pipeline `_) also contains information +# about which metrics were used to compute the model. We can access it from the model (or from the model_info) + +print(model.feature_names_in_) + +############################################################################## +# Hence, to use this model we need to create a ``sorting_analyzer`` with all these metrics computed. +# We'll do this by generating a recording and sorting, creating a sorting analyzer and computing a +# bunch of extensions. Follow these links for more info on `recordings `_, `sortings `_, `sorting analyzers `_ +# and `extensions `_. + +recording, sorting = si.generate_ground_truth_recording(num_channels=4, seed=4, num_units=10) +sorting_analyzer = si.create_sorting_analyzer(sorting=sorting, recording=recording) +sorting_analyzer.compute(['noise_levels','random_spikes','waveforms','templates','spike_locations','spike_amplitudes','correlograms','principal_components','quality_metrics','template_metrics']) +sorting_analyzer.compute('template_metrics', include_multi_channel_metrics=True) + +############################################################################## +# This sorting_analyzer now contains the required quality metrics and template metrics. +# We can check that this is true by accessing the extension data. + +all_metric_names = list(sorting_analyzer.get_extension('quality_metrics').get_data().keys()) + list(sorting_analyzer.get_extension('template_metrics').get_data().keys()) +print(set(all_metric_names) == set(model.feature_names_in_)) + +############################################################################## +# Great! We can now use the model to predict labels. Here, we pass the HF repo id directly +# to the ``auto_label_units`` function. This returns a dictionary containing a label and +# a confidence for each unit contained in the ``sorting_analyzer``. + +labels = sc.auto_label_units( + sorting_analyzer = sorting_analyzer, + repo_id = "SpikeInterface/toy_tetrode_model", + trusted = ['numpy.dtype'] +) + +print(labels) + + +############################################################################## +# The model has labelled one unit as bad. Let's look at that one, and also the 'good' unit +# with the highest confidence of being 'good'. + +sw.plot_unit_templates(sorting_analyzer, unit_ids=['7','9']) + +############################################################################## +# Nice! Unit 9 looks more like an expected action potential waveform while unit 7 doesn't, +# and it seems reasonable that unit 7 is labelled as `bad`. However, for certain experiments +# or brain areas, unit 7 might be a great small-amplitude unit. This example highlights that +# you should be careful applying models trained on one dataset to your own dataset. You can +# explore the currently available models on the `spikeinterface hugging face hub `_ +# page, or `train your own one `_. +# +# Assess the model performance +# ---------------------------- +# +# To assess the performance of the model relative to labels assigned by a human creator, we can load or generate some +# "human labels", and plot a confusion matrix of predicted vs human labels for all clusters. Here +# we'll be a conservative human, who has labelled several units with small amplitudes as 'bad'. + +human_labels = ['bad', 'good', 'good', 'bad', 'good', 'bad', 'good', 'bad', 'good', 'good'] + +# Note: if you labelled using phy, you can load the labels using: +# human_labels = sorting_analyzer.sorting.get_property('quality') +# We need to load in the `label_conversion` dictionary, which converts integers such +# as '0' and '1' to readable labels such as 'good' and 'bad'. This is stored as +# in `model_info`, which we loaded earlier. + +from sklearn.metrics import confusion_matrix, balanced_accuracy_score + +label_conversion = model_info['label_conversion'] +predictions = labels['prediction'] + +conf_matrix = confusion_matrix(human_labels, predictions) + +# Calculate balanced accuracy for the confusion matrix +balanced_accuracy = balanced_accuracy_score(human_labels, predictions) + +plt.imshow(conf_matrix) +for (index, value) in np.ndenumerate(conf_matrix): + plt.annotate( str(value), xy=index, color="white", fontsize="15") +plt.xlabel('Predicted Label') +plt.ylabel('Human Label') +plt.xticks(ticks = [0, 1], labels = list(label_conversion.values())) +plt.yticks(ticks = [0, 1], labels = list(label_conversion.values())) +plt.title('Predicted vs Human Label') +plt.suptitle(f"Balanced Accuracy: {balanced_accuracy}") +plt.show() + + +############################################################################## +# Here, there are several false positives (if we consider the human labels to be "the truth"). +# +# Next, we can also see how the model's confidence relates to the probability that the model +# label matches the human label. +# +# This could be used to help decide which units should be auto-curated and which need further +# manual creation. For example, we might accept any unit as 'good' that the model predicts +# as 'good' with confidence over a threshold, say 80%. If the confidence is lower we might decide to take a +# look at this unit manually. Below, we will create a plot that shows how the agreement +# between human and model labels changes as we increase the confidence threshold. We see that +# the agreement increases as the confidence does. So the model gets more accurate with a +# higher confidence threshold, as expceted. + + +def calculate_moving_avg(label_df, confidence_label, window_size): + + label_df[f'{confidence_label}_decile'] = pd.cut(label_df[confidence_label], 10, labels=False, duplicates='drop') + # Group by decile and calculate the proportion of correct labels (agreement) + p_label_grouped = label_df.groupby(f'{confidence_label}_decile')['model_x_human_agreement'].mean() + # Convert decile to range 0-1 + p_label_grouped.index = p_label_grouped.index / 10 + # Sort the DataFrame by confidence scores + label_df_sorted = label_df.sort_values(by=confidence_label) + + p_label_moving_avg = label_df_sorted['model_x_human_agreement'].rolling(window=window_size).mean() + + return label_df_sorted[confidence_label], p_label_moving_avg + +confidences = labels['probability'] + +# Make dataframe of human label, model label, and confidence +label_df = pd.DataFrame(data = { + 'human_label': human_labels, + 'decoder_label': predictions, + 'confidence': confidences}, + index = sorting_analyzer.sorting.get_unit_ids()) + +# Calculate the proportion of agreed labels by confidence decile +label_df['model_x_human_agreement'] = label_df['human_label'] == label_df['decoder_label'] + +p_agreement_sorted, p_agreement_moving_avg = calculate_moving_avg(label_df, 'confidence', 3) + +# Plot the moving average of agreement +plt.figure(figsize=(6, 6)) +plt.plot(p_agreement_sorted, p_agreement_moving_avg, label = 'Moving Average') +plt.axhline(y=1/len(np.unique(predictions)), color='black', linestyle='--', label='Chance') +plt.xlabel('Confidence'); #plt.xlim(0.5, 1) +plt.ylabel('Proportion Agreement with Human Label'); plt.ylim(0, 1) +plt.title('Agreement vs Confidence (Moving Average)') +plt.legend(); plt.grid(True); plt.show() + +############################################################################## +# In this case, you might decide to only trust labels which had confidence over above 0.88, +# and manually labels the ones the model isn't so confident about. +# +# A model trained on real Neuropixels data +# ---------------------------------------- +# +# Above, we used a toy model trained on generated data. There are also models on HuggingFace +# trained on real data. +# +# For example, the following classifiers are trained on Neuropixels data from 11 mice recorded in +# V1,SC and ALM: https://huggingface.co/AnoushkaJain3/noise_neural_classifier/ and +# https://huggingface.co/AnoushkaJain3/sua_mua_classifier/ . One will classify units into +# `noise` or `not-noise` and the other will classify the `not-noise` units into single +# unit activity (sua) units and multi-unit activity (mua) units. +# +# There is more information about the model on the model's HuggingFace page. Take a look! +# The idea here is to first apply the noise/not-noise classifier, then the sua/mua one. +# We can do so as follows: +# + +# Apply the noise/not-noise model +noise_neuron_labels = sc.auto_label_units( + sorting_analyzer = sorting_analyzer, + repo_id = "AnoushkaJain3/noise_neural_classifier", + trust_model=True, +) + +noise_units = noise_neuron_labels[noise_neuron_labels['prediction']=='noise'] +analyzer_neural = sorting_analyzer.remove_units(noise_units.index) + +# Apply the sua/mua model +sua_mua_labels = sc.auto_label_units( + sorting_analyzer = analyzer_neural, + repo_id = "AnoushkaJain3/sua_mua_classifier", + trust_model=True, +) + +all_labels = pd.concat([sua_mua_labels, noise_units]).sort_index() +print(all_labels) + +############################################################################## +# If you run this without the ``trust_model=True`` parameter, you will receive an error: +# +# .. code-block:: +# +# UntrustedTypesFoundException: Untrusted types found in the file: ['sklearn.metrics._classification.balanced_accuracy_score', 'sklearn.metrics._scorer._Scorer', 'sklearn.model_selection._search_successive_halving.HalvingGridSearchCV', 'sklearn.model_selection._split.StratifiedKFold'] +# +# This is a security warning, which can be overcome by passing the trusted types list +# ``trusted = ['sklearn.metrics._classification.balanced_accuracy_score', 'sklearn.metrics._scorer._Scorer', 'sklearn.model_selection._search_successive_halving.HalvingGridSearchCV', 'sklearn.model_selection._split.StratifiedKFold']`` +# or by passing the ``trust_model=True``` keyword. +# +# .. dropdown:: More about security +# +# Sharing models, with are Python objects, is complicated. +# We have chosen to use the `skops format `_, instead +# of the common but insecure ``.pkl`` format (read about ``pickle`` security issues +# `here `_). While unpacking the ``.skops`` file, each function +# is checked. Ideally, skops should recognise all `sklearn`, `numpy` and `scipy` functions and +# allow the object to be loaded if it only contains these (and no unkown malicious code). But +# when ``skops`` it's not sure, it raises an error. Here, it doesn't recognise +# ``['sklearn.metrics._classification.balanced_accuracy_score', 'sklearn.metrics._scorer._Scorer', +# 'sklearn.model_selection._search_successive_halving.HalvingGridSearchCV', +# 'sklearn.model_selection._split.StratifiedKFold']``. Taking a look, these are all functions +# from `sklearn`, and we can happily add them to the ``trusted`` functions to load. +# +# In general, you should be cautious when downloading ``.skops`` files and ``.pkl`` files from repos, +# especially from unknown sources. +# +# Directly applying a sklearn Pipeline +# ------------------------------------ +# +# Instead of using ``HuggingFace`` and ``skops``, someone might have given you a model +# in differet way: perhaps by e-mail or a download. If you have the model in a +# folder, you can apply it in a very similar way: +# +# .. code-block:: +# +# labels = sc.auto_label_units( +# sorting_analyzer = sorting_analyzer, +# model_folder = "path/to/model/folder", +# ) + +############################################################################## +# Using this, you lose the advantages of the model metadata: the quality metric parameters +# are not checked and the labels are not converted their original human readable names (like +# 'good' and 'bad'). Hence we advise using the methods discussed above, when possible. diff --git a/examples/tutorials/curation/plot_2_train_a_model.py b/examples/tutorials/curation/plot_2_train_a_model.py new file mode 100644 index 0000000000..1a38836527 --- /dev/null +++ b/examples/tutorials/curation/plot_2_train_a_model.py @@ -0,0 +1,168 @@ +""" +Training a model for automated curation +============================= + +If the pretrained models do not give satisfactory performance on your data, it is easy to train your own classifier using SpikeInterface. +""" + + +############################################################################## +# Step 1: Generate and label data +# ------------------------------- +# +# First we will import our dependencies +import warnings +warnings.filterwarnings("ignore") +from pathlib import Path +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt + +import spikeinterface.core as si +import spikeinterface.curation as sc +import spikeinterface.widgets as sw + +# Note, you can set the number of cores you use using e.g. +# si.set_global_job_kwargs(n_jobs = 8) + +############################################################################## +# For this tutorial, we will use simulated data to create ``recording`` and ``sorting`` objects. We'll +# create two sorting objects: :code:`sorting_1` is coupled to the real recording, so the spike times of the sorter will +# perfectly match the spikes in the recording. Hence this will contain good units. However, we've +# uncoupled :code:`sorting_2` to the recording and the spike times will not be matched with the spikes in the recording. +# Hence these units will mostly be random noise. We'll combine the "good" and "noise" sortings into one sorting +# object using :code:`si.aggregate_units`. +# +# (When making your own model, you should +# `load your own recording `_ +# and `do a sorting `_ on your data.) + +recording, sorting_1 = si.generate_ground_truth_recording(num_channels=4, seed=1, num_units=5) +_, sorting_2 =si.generate_ground_truth_recording(num_channels=4, seed=2, num_units=5) + +both_sortings = si.aggregate_units([sorting_1, sorting_2]) + +############################################################################## +# To do some visualisation and postprocessing, we need to create a sorting analyzer, and +# compute some extensions: + +analyzer = si.create_sorting_analyzer(sorting = both_sortings, recording=recording) +analyzer.compute(['noise_levels','random_spikes','waveforms','templates']) + +############################################################################## +# Now we can plot the templates for the first and fifth units. The first (unit id 0) belongs to +# :code:`sorting_1` so should look like a real unit; the sixth (unit id 5) belongs to :code:`sorting_2` +# so should look like noise. + +sw.plot_unit_templates(analyzer, unit_ids=["0", "5"]) + +############################################################################## +# This is as expected: great! (Find out more about plotting using widgets `here `_.) +# We've set up our system so that the first five units are 'good' and the next five are 'bad'. +# So we can make a list of labels which contain this information. For real data, you could +# use a manual curation tool to make your own list. + +labels = ['good', 'good', 'good', 'good', 'good', 'bad', 'bad', 'bad', 'bad', 'bad'] + +############################################################################## +# Step 2: Train our model +# ----------------------- +# +# We'll now train a model, based on our labelled data. The model will be trained using properties +# of the units, and then be applied to units from other sortings. The properties we use are the +# `quality metrics `_ +# and `template metrics `_. +# Hence we need to compute these, using some ``sorting_analyzer``` extensions. + +analyzer.compute(['spike_locations','spike_amplitudes','correlograms','principal_components','quality_metrics','template_metrics']) + +############################################################################## +# Now that we have metrics and labels, we're ready to train the model using the +# ``train_model``` function. The trainer will try several classifiers, imputation strategies and +# scaling techniques then save the most accurate. To save time in this tutorial, +# we'll only try one classifier (Random Forest), imputation strategy (median) and scaling +# technique (standard scaler). +# +# We will use a list of one analyzer here, so the model is trained on a single +# session. In reality, we would usually train a model using multiple analyzers from an +# experiment, which should make the model more robust. To do this, you can simply pass +# a list of analyzers and a list of manually curated labels for each +# of these analyzers. Then the model would use all of these data as input. + +trainer = sc.train_model( + mode = "analyzers", # You can supply a labelled csv file instead of an analyzer + labels = [labels], + analyzers = [analyzer], + folder = "my_folder", # Where to save the model and model_info.json file + metric_names = None, # Specify which metrics to use for training: by default uses those already calculted + imputation_strategies = ["median"], # Defaults to all + scaling_techniques = ["standard_scaler"], # Defaults to all + classifiers = None, # Default to Random Forest only. Other classifiers you can try [ "AdaBoostClassifier","GradientBoostingClassifier","LogisticRegression","MLPClassifier"] + overwrite = True, # Whether or not to overwrite `folder` if it already exists. Default is False. + search_kwargs = {'cv': 3} # Parameters used during the model hyperparameter search +) + +best_model = trainer.best_pipeline + +############################################################################## +# +# You can pass many sklearn `classifiers `_ +# `imputation strategies `_ and +# `scalers `_, although the +# documentation is quite overwhelming. You can find the classifiers we've tried out +# using the ``sc.get_default_classifier_search_spaces`` function. +# +# The above code saves the model in ``model.skops``, some metadata in +# ``model_info.json`` and the model accuracies in ``model_accuracies.csv`` +# in the specified ``folder`` (in this case ``'my_folder'``). +# +# (``skops`` is a file format: you can think of it as a more-secure pkl file. `Read more `_.) +# +# The ``model_accuracies.csv`` file contains the accuracy, precision and recall of the +# tested models. Let's take a look: + +accuracies = pd.read_csv(Path("my_folder") / "model_accuracies.csv", index_col = 0) +accuracies.head() + +############################################################################## +# Our model is perfect!! This is because the task was *very* easy. We had 10 units; where +# half were pure noise and half were not. +# +# The model also contains some more information, such as which features are "important", +# as defined by sklearn (learn about feature importance of a Random Forest Classifier +# `here `_.) +# We can plot these: + +# Plot feature importances +importances = best_model.named_steps['classifier'].feature_importances_ +indices = np.argsort(importances)[::-1] + +# The sklearn importances are not computed for inputs whose values are all `nan`. +# Hence, we need to pick out the non-`nan` columns of our metrics +features = best_model.feature_names_in_ +n_features = best_model.n_features_in_ + +metrics = pd.concat([analyzer.get_extension('quality_metrics').get_data(), analyzer.get_extension('template_metrics').get_data()], axis=1) +non_null_metrics = ~(metrics.isnull().all()).values + +features = features[non_null_metrics] +n_features = len(features) + +plt.figure(figsize=(12, 7)) +plt.title("Feature Importances") +plt.bar(range(n_features), importances[indices], align="center") +plt.xticks(range(n_features), features[indices], rotation=90) +plt.xlim([-1, n_features]) +plt.subplots_adjust(bottom=0.3) +plt.show() + +############################################################################## +# Roughly, this means the model is using metrics such as "nn_hit_rate" and "l_ratio" +# but is not using "sync_spike_4" and "rp_contanimation". This is a toy model, so don't +# take these results seriously. But using this information, you could retrain another, +# simpler model using a subset of the metrics, by passing, e.g., +# ``metric_names = ['nn_hit_rate', 'l_ratio',...]`` to the ``train_model`` function. +# +# Now that you have a model, you can `apply it to another sorting +# `_ +# or `upload it to HuggingFaceHub `_. diff --git a/examples/tutorials/curation/plot_3_upload_a_model.py b/examples/tutorials/curation/plot_3_upload_a_model.py new file mode 100644 index 0000000000..0a9ea402db --- /dev/null +++ b/examples/tutorials/curation/plot_3_upload_a_model.py @@ -0,0 +1,139 @@ +""" +Upload a pipeline to Hugging Face Hub +===================================== +""" +############################################################################## +# In this tutorial we will upload a pipeline, trained in SpikeInterface, to the +# `Hugging Face Hub `_ (HFH). +# +# To do this, you first need to train a model. `Learn how here! `_ +# +# Hugging Face Hub? +# ----------------- +# Hugging Face Hub (HFH) is a model sharing platform focused on AI and ML models and datasets. +# To upload your own model to HFH, you need to make an account with them. +# If you do not want to make an account, you can simply share the model folder with colleagues. +# There are also several ways to interaction with HFH: the way we propose here doesn't use +# many of the tools ``skops`` and hugging face have developed such as the ``Card`` and +# ``hub_utils``. Feel free to check those out `here `_. +# +# Prepare your model +# ------------------ +# +# The plan is to make a folder with the following file structure +# +# .. code-block:: +# +# my_model_folder/ +# my_model_name.skops +# model_info.json +# training_data.csv +# labels.csv +# metadata.json +# +# SpikeInterface and HFH don't require you to keep this folder structure, we just advise it as +# best practice. +# +# If you've used SpikeInterface to train your model, the ``train_model`` function auto-generates +# most of this data. The only thing missing is the the ``metadata.json`` file. The purpose of this +# file is to detail how the model was trained, which can help prospective users decide if it +# is relevant for them. For example, taking +# a model trained on mouse data and applying it to a primate is likely a bad idea (or a +# great research paper!). And a model trained using tetrode data might have limited application +# on a silcone high-density probes. Hence we suggest saving at least the species, brain areas +# and probe information, as is done in the dictionary below. Note that we format the metadata +# so that the information +# in common with the NWB data format is consistent with it. Since the models can be trained +# on several curations, all the metadata fields are lists: +# +# .. code-block:: +# +# import json +# +# model_metadata = { +# "subject_species": ["Mus musculus"], +# "brain_areas": ["CA1"], +# "probes": +# [{ +# "manufacturer": "IMEc", +# "name": "Neuropixels 2.0" +# }] +# } +# with open("my_model_folder/metadata.json", "w") as file: +# json.dump(model_metadata, file) +# +# Upload to HuggingFaceHub +# ------------------------ +# +# We'll now upload this folder to HFH using the web interface. +# +# First, go to https://huggingface.co/ and make an account. Once you've logged in, press +# ``+`` then ``New model`` or find ``+ New Model`` in the user menu. You will be asked +# to enter a model name, to choose a license for the model and whether the model should +# be public or private. After you have made these choices, press ``Create Model``. +# +# You should be on your model's landing page, whose header looks something like +# +# .. image:: ../../images/initial_model_screen.png +# :width: 550 +# :align: center +# :alt: The page shown on HuggingFaceHub when a user first initialises a model +# +# Click Files, then ``+ Add file`` then ``Upload file(s)``. You can then add your files to the repository. Upload these by pressing ``Commit changes to main``. +# +# You are returned to the Files page, which should look similar to +# +# .. image:: ../../images/files_screen.png +# :width: 700 +# :align: center +# :alt: The file list for a model HuggingFaceHub. +# +# Let's add some information about the model for users to see when they go on your model's +# page. Click on ``Model card`` then ``Edit model card``. Here is a sample model card for +# For a model based on synthetically generated tetrode data, +# +# .. code-block:: +# +# --- +# license: mit +# --- +# +# ## Model description +# +# A toy model, trained on toy data generated from spikeinterface. +# +# # Intended use +# +# Used to try out automated curation in SpikeInterface. +# +# # How to Get Started with the Model +# +# This can be used to automatically label a sorting in spikeinterface. Provided you have a `sorting_analyzer`, it is used as follows +# +# ` ` ` python (NOTE: you should remove the spaces between each backtick. This is just formatting for the notebook you are reading) +# +# from spikeinterface.curation import auto_label_units +# labels = auto_label_units( +# sorting_analyzer = sorting_analyzer, +# repo_id = "SpikeInterface/toy_tetrode_model", +# trust_model=True +# ) +# ` ` ` +# +# or you can download the entire repositry to `a_folder_for_a_model`, and use +# +# ` ` ` python +# from spikeinterface.curation import auto_label_units +# +# labels = auto_label_units( +# sorting_analyzer = sorting_analyzer, +# model_folder = "path/to/a_folder_for_a_model", +# trusted = ['numpy.dtype'] +# ) +# ` ` ` +# +# # Authors +# +# Chris Halcrow +# +# You can see the repo with this Model card `here `_. diff --git a/examples/tutorials/qualitymetrics/plot_3_quality_mertics.py b/examples/tutorials/qualitymetrics/plot_3_quality_metrics.py similarity index 100% rename from examples/tutorials/qualitymetrics/plot_3_quality_mertics.py rename to examples/tutorials/qualitymetrics/plot_3_quality_metrics.py diff --git a/pyproject.toml b/pyproject.toml index 22fbdc7f22..0b2f06049f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,6 +101,8 @@ full = [ "matplotlib>=3.6", # matplotlib.colormaps "cuda-python; platform_system != 'Darwin'", "numba", + "skops", + "huggingface_hub" ] widgets = [ @@ -171,6 +173,10 @@ test = [ "torch", "pynndescent", + # curation + "skops", + "huggingface_hub", + # for github test : probeinterface and neo from master # for release we need pypi, so this need to be commented "probeinterface @ git+https://github.com/SpikeInterface/probeinterface.git", @@ -192,6 +198,8 @@ docs = [ "hdbscan>=0.8.33", # For sorters spykingcircus2 + tridesclous "numba", # For many postprocessing functions "networkx", + "skops", # For auotmated curation + "scikit-learn", # For auotmated curation # Download data "pooch>=1.8.2", "datalad>=1.0.2", diff --git a/src/spikeinterface/curation/__init__.py b/src/spikeinterface/curation/__init__.py index 0302ffe5b7..975f2fe22f 100644 --- a/src/spikeinterface/curation/__init__.py +++ b/src/spikeinterface/curation/__init__.py @@ -15,3 +15,7 @@ from .curation_format import validate_curation_dict, curation_label_to_dataframe, apply_curation from .sortingview_curation import apply_sortingview_curation + +# automated curation +from .model_based_curation import auto_label_units, load_model +from .train_manual_curation import train_model, get_default_classifier_search_spaces diff --git a/src/spikeinterface/curation/model_based_curation.py b/src/spikeinterface/curation/model_based_curation.py new file mode 100644 index 0000000000..93ad03734c --- /dev/null +++ b/src/spikeinterface/curation/model_based_curation.py @@ -0,0 +1,435 @@ +import numpy as np +from pathlib import Path +import json +import warnings +import re + +from spikeinterface.core import SortingAnalyzer +from spikeinterface.curation.train_manual_curation import ( + try_to_get_metrics_from_analyzer, + _get_computed_metrics, + _format_metric_dataframe, +) +from copy import deepcopy + + +class ModelBasedClassification: + """ + Class for performing model-based classification on spike sorting data. + + Parameters + ---------- + sorting_analyzer : SortingAnalyzer + The sorting analyzer object containing the spike sorting data. + pipeline : Pipeline + The pipeline object representing the trained classification model. + + Attributes + ---------- + sorting_analyzer : SortingAnalyzer + The sorting analyzer object containing the spike sorting data. + pipeline : Pipeline + The pipeline object representing the trained classification model. + required_metrics : Sequence[str] + The list of required metrics for classification, extracted from the pipeline. + + Methods + ------- + predict_labels() + Predicts the labels for the spike sorting data using the trained model. + """ + + def __init__(self, sorting_analyzer: SortingAnalyzer, pipeline): + from sklearn.pipeline import Pipeline + + if not isinstance(pipeline, Pipeline): + raise ValueError("The `pipeline` must be an instance of sklearn.pipeline.Pipeline") + + self.sorting_analyzer = sorting_analyzer + self.pipeline = pipeline + self.required_metrics = pipeline.feature_names_in_ + + def predict_labels( + self, label_conversion=None, input_data=None, export_to_phy=False, model_info=None, enforce_metric_params=False + ): + """ + Predicts the labels for the spike sorting data using the trained model. + Populates the sorting object with the predicted labels and probabilities as unit properties + + Parameters + ---------- + model_info : dict or None, default: None + Model info, generated with model, used to check metric parameters used to train it. + label_conversion : dict or None, default: None + A dictionary for converting the predicted labels (which are integers) to custom labels. If None, + tries to find in `model_info` file. The dictionary should have the format {old_label: new_label}. + input_data : pandas.DataFrame or None, default: None + The input data for classification. If not provided, the method will extract metrics stored in the sorting analyzer. + export_to_phy : bool, default: False. + Whether to export the classified units to Phy format. Default is False. + enforce_metric_params : bool, default: False + If True and the parameters used to compute the metrics in `sorting_analyzer` are different than the parmeters + used to compute the metrics used to train the model, this function will raise an error. Otherwise, a warning is raised. + + Returns + ------- + pd.DataFrame + A dataframe containing the classified units and their corresponding predictions and probabilities, + indexed by their `unit_ids`. + """ + import pandas as pd + + # Get metrics DataFrame for classification + if input_data is None: + input_data = _get_computed_metrics(self.sorting_analyzer) + else: + if not isinstance(input_data, pd.DataFrame): + raise ValueError("Input data must be a pandas DataFrame") + + input_data = self._check_required_metrics_are_present(input_data) + + if model_info is not None: + self._check_params_for_classification(enforce_metric_params, model_info=model_info) + + if model_info is not None and label_conversion is None: + try: + string_label_conversion = model_info["label_conversion"] + # json keys are strings; we convert these to ints + label_conversion = {} + for key, value in string_label_conversion.items(): + label_conversion[int(key)] = value + except: + warnings.warn("Could not find `label_conversion` key in `model_info.json` file") + + input_data = _format_metric_dataframe(input_data) + + # Apply classifier + predictions = self.pipeline.predict(input_data) + probabilities = self.pipeline.predict_proba(input_data) + probabilities = np.max(probabilities, axis=1) + + if isinstance(label_conversion, dict): + + if set(predictions).issubset(set(label_conversion.keys())) is False: + raise ValueError("Labels in predictions do not match those in label_conversion") + predictions = [label_conversion[label] for label in predictions] + + classified_units = pd.DataFrame( + zip(predictions, probabilities), columns=["prediction", "probability"], index=self.sorting_analyzer.unit_ids + ) + + # Set predictions and probability as sorting properties + self.sorting_analyzer.sorting.set_property("classifier_label", predictions) + self.sorting_analyzer.sorting.set_property("classifier_probability", probabilities) + + if export_to_phy: + self._export_to_phy(classified_units) + + return classified_units + + def _check_required_metrics_are_present(self, calculated_metrics): + + # Check all the required metrics have been calculated + required_metrics = set(self.required_metrics) + if required_metrics.issubset(set(calculated_metrics)): + input_data = calculated_metrics[self.required_metrics] + else: + raise ValueError( + "Input data does not contain all required metrics for classification", + f"Missing metrics: {required_metrics.difference(calculated_metrics)}", + ) + + return input_data + + def _check_params_for_classification(self, enforce_metric_params=False, model_info=None): + """ + Check that quality and template metrics parameters match those used to train the model + + Parameters + ---------- + enforce_metric_params : bool, default: False + If True and the parameters used to compute the metrics in `sorting_analyzer` are different than the parmeters + used to compute the metrics used to train the model, this function will raise an error. Otherwise, a warning is raised. + model_info : dict, default: None + Dictionary of model info containing provenance of the model. + """ + + extension_names = ["quality_metrics", "template_metrics"] + + metric_extensions = [self.sorting_analyzer.get_extension(extension_name) for extension_name in extension_names] + + for metric_extension, extension_name in zip(metric_extensions, extension_names): + + # remove the 's' at the end of the extension name + extension_name = extension_name[:-1] + model_extension_params = model_info["metric_params"].get(extension_name + "_params") + + if metric_extension is not None and model_extension_params is not None: + + metric_params = metric_extension.params["metric_params"] + + inconsistent_metrics = [] + for metric in model_extension_params["metric_names"]: + model_metric_params = model_extension_params.get("metric_params") + if model_metric_params is None or metric not in model_metric_params: + inconsistent_metrics.append(metric) + else: + if metric_params[metric] != model_metric_params[metric]: + warning_message = f"{extension_name} params for {metric} do not match those used to train the model. Parameters can be found in the 'model_info.json' file." + if enforce_metric_params is True: + raise Exception(warning_message) + else: + warnings.warn(warning_message) + + if len(inconsistent_metrics) > 0: + warning_message = f"Parameters used to compute metrics {inconsistent_metrics}, used to train this model, are unknown." + if enforce_metric_params is True: + raise Exception(warning_message) + else: + warnings.warn(warning_message) + + def _export_to_phy(self, classified_units): + """Export the classified units to Phy as cluster_prediction.tsv file""" + + import pandas as pd + + # Create a new DataFrame with unit_id, prediction, and probability columns from dict {unit_id: (prediction, probability)} + classified_df = pd.DataFrame.from_dict(classified_units, orient="index", columns=["prediction", "probability"]) + + # Export to Phy format + try: + sorting_path = self.sorting_analyzer.sorting.get_annotation("phy_folder") + assert sorting_path is not None + assert Path(sorting_path).is_dir() + except AssertionError: + raise ValueError("Phy folder not found in sorting annotations, or is not a directory") + + classified_df.to_csv(f"{sorting_path}/cluster_prediction.tsv", sep="\t", index_label="cluster_id") + + +def auto_label_units( + sorting_analyzer: SortingAnalyzer, + model_folder=None, + model_name=None, + repo_id=None, + label_conversion=None, + trust_model=False, + trusted=None, + export_to_phy=False, + enforce_metric_params=False, +): + """ + Automatically labels units based on a model-based classification, either from a model + hosted on HuggingFaceHub or one available in a local folder. + + This function returns the predicted labels and the prediction probabilities, and populates + the sorting object with the predicted labels and probabilities in the 'classifier_label' and + 'classifier_probability' properties. + + Parameters + ---------- + sorting_analyzer : SortingAnalyzer + The sorting analyzer object containing the spike sorting results. + model_folder : str or Path, defualt: None + The path to the folder containing the model + repo_id : str | Path, default: None + Hugging face repo id which contains the model e.g. 'username/model' + model_name: str | Path, default: None + Filename of model e.g. 'my_model.skops'. If None, uses first model found. + label_conversion : dic | None, default: None + A dictionary for converting the predicted labels (which are integers) to custom labels. If None, + tries to extract from `model_info.json` file. The dictionary should have the format {old_label: new_label}. + export_to_phy : bool, default: False + Whether to export the results to Phy format. Default is False. + trust_model : bool, default: False + Whether to trust the model. If True, the `trusted` parameter that is passed to `skops.load` to load the model will be + automatically inferred. If False, the `trusted` parameter must be provided to indicate the trusted objects. + trusted : list of str, default: None + Passed to skops.load. The object will be loaded only if there are only trusted objects and objects of types listed in trusted in the dumped file. + enforce_metric_params : bool, default: False + If True and the parameters used to compute the metrics in `sorting_analyzer` are different than the parmeters + used to compute the metrics used to train the model, this function will raise an error. Otherwise, a warning is raised. + + + Returns + ------- + classified_units : pd.DataFrame + A dataframe containing the classified units, indexed by the `unit_ids`, containing the predicted label + and confidence probability of each labelled unit. + + Raises + ------ + ValueError + If the pipeline is not an instance of sklearn.pipeline.Pipeline. + + """ + from sklearn.pipeline import Pipeline + + model, model_info = load_model( + model_folder=model_folder, repo_id=repo_id, model_name=model_name, trust_model=trust_model, trusted=trusted + ) + + if not isinstance(model, Pipeline): + raise ValueError("The model must be an instance of sklearn.pipeline.Pipeline") + + model_based_classification = ModelBasedClassification(sorting_analyzer, model) + + classified_units = model_based_classification.predict_labels( + label_conversion=label_conversion, + export_to_phy=export_to_phy, + model_info=model_info, + enforce_metric_params=enforce_metric_params, + ) + + return classified_units + + +def load_model(model_folder=None, repo_id=None, model_name=None, trust_model=False, trusted=None): + """ + Loads a model and model_info from a HuggingFaceHub repo or a local folder. + + Parameters + ---------- + model_folder : str or Path, defualt: None + The path to the folder containing the model + repo_id : str | Path, default: None + Hugging face repo id which contains the model e.g. 'username/model' + model_name: str | Path, default: None + Filename of model e.g. 'my_model.skops'. If None, uses first model found. + trust_model : bool, default: False + Whether to trust the model. If True, the `trusted` parameter that is passed to `skops.load` to load the model will be + automatically inferred. If False, the `trusted` parameter must be provided to indicate the trusted objects. + trusted : list of str, default: None + Passed to skops.load. The object will be loaded only if there are only trusted objects and objects of types listed in trusted in the dumped file. + + + Returns + ------- + model, model_info + A model and metadata about the model + """ + + if model_folder is None and repo_id is None: + raise ValueError("Please provide a 'model_folder' or a 'repo_id'.") + elif model_folder is not None and repo_id is not None: + raise ValueError("Please only provide one of 'model_folder' or 'repo_id'.") + elif model_folder is not None: + model, model_info = _load_model_from_folder( + model_folder=model_folder, model_name=model_name, trust_model=trust_model, trusted=trusted + ) + else: + model, model_info = _load_model_from_huggingface( + repo_id=repo_id, model_name=model_name, trust_model=trust_model, trusted=trusted + ) + + return model, model_info + + +def _load_model_from_huggingface(repo_id=None, model_name=None, trust_model=False, trusted=None): + """ + Loads a model from a huggingface repo + + Returns + ------- + model, model_info + A model and metadata about the model + """ + + from huggingface_hub import list_repo_files + from huggingface_hub import hf_hub_download + + # get repo filenames + repo_filenames = list_repo_files(repo_id=repo_id) + + # download all skops and json files to temp directory + for filename in repo_filenames: + if Path(filename).suffix in [".skops", ".json"]: + full_path = hf_hub_download(repo_id=repo_id, filename=filename) + model_folder = Path(full_path).parent + + model, model_info = _load_model_from_folder( + model_folder=model_folder, model_name=model_name, trust_model=trust_model, trusted=trusted + ) + + return model, model_info + + +def _load_model_from_folder(model_folder=None, model_name=None, trust_model=False, trusted=None): + """ + Loads a model and model_info from a folder + + Returns + ------- + model, model_info + A model and metadata about the model + """ + + import skops.io as skio + from skops.io.exceptions import UntrustedTypesFoundException + + folder = Path(model_folder) + assert folder.is_dir(), f"The folder {folder}, does not exist." + + # look for any .skops files + skops_files = list(folder.glob("*.skops")) + assert len(skops_files) > 0, f"There are no '.skops' files in the folder {folder}" + + if len(skops_files) > 1: + if model_name is None: + model_names = [f.name for f in skops_files] + raise ValueError( + f"There are more than 1 '.skops' file in folder {folder}. You have to specify " + f"the file using the 'model_name' argument. Available files:\n{model_names}" + ) + else: + skops_file = folder / Path(model_name) + assert skops_file.is_file(), f"Model file {skops_file} not found." + elif len(skops_files) == 1: + skops_file = skops_files[0] + + if trust_model and trusted is None: + try: + model = skio.load(skops_file) + except UntrustedTypesFoundException as e: + exception_msg = str(e) + # the exception message contains the list of untrusted objects. The following + # search assumes it is the only list in the message. + string_list = re.search(r"\[(.*?)\]", exception_msg).group() + trusted = [list_item for list_item in string_list.split("'") if len(list_item) > 2] + + model = skio.load(skops_file, trusted=trusted) + + model_info_path = folder / "model_info.json" + if not model_info_path.is_file(): + warnings.warn("No 'model_info.json' file found in folder. No metadata can be checked.") + model_info = None + else: + model_info = json.load(open(model_info_path)) + + model_info = handle_backwards_compatibility_metric_params(model_info) + + return model, model_info + + +def handle_backwards_compatibility_metric_params(model_info): + + if ( + model_info.get("metric_params") is not None + and model_info.get("metric_params").get("quality_metric_params") is not None + ): + if (qm_params := model_info["metric_params"]["quality_metric_params"].get("qm_params")) is not None: + model_info["metric_params"]["quality_metric_params"]["metric_params"] = qm_params + del model_info["metric_params"]["quality_metric_params"]["qm_params"] + + if ( + model_info.get("metric_params") is not None + and model_info.get("metric_params").get("template_metric_params") is not None + ): + if (tm_params := model_info["metric_params"]["template_metric_params"].get("metrics_kwargs")) is not None: + metric_params = {} + for metric_name in model_info["metric_params"]["template_metric_params"].get("metric_names"): + metric_params[metric_name] = deepcopy(tm_params) + model_info["metric_params"]["template_metric_params"]["metric_params"] = metric_params + del model_info["metric_params"]["template_metric_params"]["metrics_kwargs"] + + return model_info diff --git a/src/spikeinterface/curation/tests/test_model_based_curation.py b/src/spikeinterface/curation/tests/test_model_based_curation.py new file mode 100644 index 0000000000..3683b417df --- /dev/null +++ b/src/spikeinterface/curation/tests/test_model_based_curation.py @@ -0,0 +1,167 @@ +import pytest +from pathlib import Path +from spikeinterface.curation.tests.common import make_sorting_analyzer, sorting_analyzer_for_curation +from spikeinterface.curation.model_based_curation import ModelBasedClassification +from spikeinterface.curation import auto_label_units, load_model +from spikeinterface.curation.train_manual_curation import _get_computed_metrics + +import numpy as np + +if hasattr(pytest, "global_test_folder"): + cache_folder = pytest.global_test_folder / "curation" +else: + cache_folder = Path("cache_folder") / "curation" + + +@pytest.fixture +def model(): + """A toy model, created using the `sorting_analyzer_for_curation` from `spikeinterface.curation.tests.common`. + It has been trained locally and, when applied to `sorting_analyzer_for_curation` will label its 5 units with + the following labels: [1,0,1,0,1].""" + + model = load_model(Path(__file__).parent / "trained_pipeline/", trusted=["numpy.dtype"]) + return model + + +@pytest.fixture +def required_metrics(): + """These are the metrics which `model` are trained on.""" + return ["num_spikes", "snr", "half_width"] + + +def test_model_based_classification_init(sorting_analyzer_for_curation, model): + """Test that the ModelBasedClassification attributes are correctly initialised""" + + model_based_classification = ModelBasedClassification(sorting_analyzer_for_curation, model[0]) + assert model_based_classification.sorting_analyzer == sorting_analyzer_for_curation + assert model_based_classification.pipeline == model[0] + assert np.all(model_based_classification.required_metrics == model_based_classification.pipeline.feature_names_in_) + + +def test_metric_ordering_independence(sorting_analyzer_for_curation, model): + """The function `auto_label_units` needs the correct metrics to have been computed. However, + it should be independent of the order of computation. We test this here.""" + + sorting_analyzer_for_curation.compute("template_metrics", metric_names=["half_width"]) + sorting_analyzer_for_curation.compute("quality_metrics", metric_names=["num_spikes", "snr"]) + + model_folder = Path(__file__).parent / Path("trained_pipeline") + + prediction_prob_dataframe_1 = auto_label_units( + sorting_analyzer=sorting_analyzer_for_curation, + model_folder=model_folder, + trusted=["numpy.dtype"], + ) + + sorting_analyzer_for_curation.compute("quality_metrics", metric_names=["snr", "num_spikes"]) + + prediction_prob_dataframe_2 = auto_label_units( + sorting_analyzer=sorting_analyzer_for_curation, + model_folder=model_folder, + trusted=["numpy.dtype"], + ) + + assert prediction_prob_dataframe_1.equals(prediction_prob_dataframe_2) + + +def test_model_based_classification_get_metrics_for_classification( + sorting_analyzer_for_curation, model, required_metrics +): + """If the user has not computed the required metrics, an error should be returned. + This test checks that an error occurs when the required metrics have not been computed, + and that no error is returned when the required metrics have been computed. + """ + + sorting_analyzer_for_curation.delete_extension("quality_metrics") + sorting_analyzer_for_curation.delete_extension("template_metrics") + + model_based_classification = ModelBasedClassification(sorting_analyzer_for_curation, model[0]) + + # Check that ValueError is returned when no metrics are present in sorting_analyzer + with pytest.raises(ValueError): + computed_metrics = _get_computed_metrics(sorting_analyzer_for_curation) + + # Compute some (but not all) of the required metrics in sorting_analyzer, should still error + sorting_analyzer_for_curation.compute("quality_metrics", metric_names=[required_metrics[0]]) + computed_metrics = _get_computed_metrics(sorting_analyzer_for_curation) + with pytest.raises(ValueError): + model_based_classification._check_required_metrics_are_present(computed_metrics) + + # Compute all of the required metrics in sorting_analyzer, no more error + sorting_analyzer_for_curation.compute("quality_metrics", metric_names=required_metrics[0:2]) + sorting_analyzer_for_curation.compute("template_metrics", metric_names=[required_metrics[2]]) + + metrics_data = _get_computed_metrics(sorting_analyzer_for_curation) + assert metrics_data.shape[0] == len(sorting_analyzer_for_curation.sorting.get_unit_ids()) + assert set(metrics_data.columns.to_list()) == set(required_metrics) + + +def test_model_based_classification_export_to_phy(sorting_analyzer_for_curation, model): + # Test the _export_to_phy() method of ModelBasedClassification + model_based_classification = ModelBasedClassification(sorting_analyzer_for_curation, model[0]) + classified_units = {0: (1, 0.5), 1: (0, 0.5), 2: (1, 0.5), 3: (0, 0.5), 4: (1, 0.5)} + # Function should fail here + with pytest.raises(ValueError): + model_based_classification._export_to_phy(classified_units) + # Make temp output folder and set as phy_folder + phy_folder = cache_folder / "phy_folder" + phy_folder.mkdir(parents=True, exist_ok=True) + + model_based_classification.sorting_analyzer.sorting.annotate(phy_folder=phy_folder) + model_based_classification._export_to_phy(classified_units) + assert (phy_folder / "cluster_prediction.tsv").exists() + + +def test_model_based_classification_predict_labels(sorting_analyzer_for_curation, model): + """The model `model` has been trained on the `sorting_analyzer` used in this test with + the labels `[1, 0, 1, 0, 1]`. Hence if we apply the model to this `sorting_analyzer` + we expect these labels to be outputted. The test checks this, and also checks + that label conversion works as expected.""" + + sorting_analyzer_for_curation.compute("template_metrics", metric_names=["half_width"]) + sorting_analyzer_for_curation.compute("quality_metrics", metric_names=["num_spikes", "snr"]) + + # Test the predict_labels() method of ModelBasedClassification + model_based_classification = ModelBasedClassification(sorting_analyzer_for_curation, model[0]) + classified_units = model_based_classification.predict_labels() + predictions = classified_units["prediction"].values + + assert np.all(predictions == np.array([1, 0, 1, 0, 1])) + + conversion = {0: "noise", 1: "good"} + classified_units_labelled = model_based_classification.predict_labels(label_conversion=conversion) + predictions_labelled = classified_units_labelled["prediction"] + assert np.all(predictions_labelled == ["good", "noise", "good", "noise", "good"]) + + +def test_exception_raised_when_metricparams_not_equal(sorting_analyzer_for_curation): + """We track whether the metric parameters used to compute the metrics used to train + a model are the same as the parameters used to compute the metrics in the sorting + analyzer which is being curated. If they are different, an error or warning will + be raised depending on the `enforce_metric_params` kwarg. This behaviour is tested here.""" + + sorting_analyzer_for_curation.compute( + "quality_metrics", metric_names=["num_spikes", "snr"], metric_params={"snr": {"peak_mode": "peak_to_peak"}} + ) + sorting_analyzer_for_curation.compute("template_metrics", metric_names=["half_width"]) + + model_folder = Path(__file__).parent / Path("trained_pipeline") + + model, model_info = load_model(model_folder=model_folder, trusted=["numpy.dtype"]) + model_based_classification = ModelBasedClassification(sorting_analyzer_for_curation, model) + + # an error should be raised if `enforce_metric_params` is True + with pytest.raises(Exception): + model_based_classification._check_params_for_classification(enforce_metric_params=True, model_info=model_info) + + # but only a warning if `enforce_metric_params` is False + with pytest.warns(UserWarning): + model_based_classification._check_params_for_classification(enforce_metric_params=False, model_info=model_info) + + # Now test the positive case. Recompute using the default parameters + sorting_analyzer_for_curation.compute("quality_metrics", metric_names=["num_spikes", "snr"], metric_params={}) + sorting_analyzer_for_curation.compute("template_metrics", metric_names=["half_width"]) + + model, model_info = load_model(model_folder=model_folder, trusted=["numpy.dtype"]) + model_based_classification = ModelBasedClassification(sorting_analyzer_for_curation, model) + model_based_classification._check_params_for_classification(enforce_metric_params=True, model_info=model_info) diff --git a/src/spikeinterface/curation/tests/test_train_manual_curation.py b/src/spikeinterface/curation/tests/test_train_manual_curation.py new file mode 100644 index 0000000000..f455fbdb9c --- /dev/null +++ b/src/spikeinterface/curation/tests/test_train_manual_curation.py @@ -0,0 +1,285 @@ +import pytest +import numpy as np +import tempfile, csv +from pathlib import Path + +from spikeinterface.curation.tests.common import make_sorting_analyzer +from spikeinterface.curation.train_manual_curation import CurationModelTrainer, train_model + + +@pytest.fixture +def trainer(): + """A simple CurationModelTrainer object is created, which can later by used to + train models using data from `sorting_analyzer`s.""" + + folder = tempfile.mkdtemp() # Create a temporary output folder + imputation_strategies = ["median"] + scaling_techniques = ["standard_scaler"] + classifiers = ["LogisticRegression"] + metric_names = ["metric1", "metric2", "metric3"] + search_kwargs = {"cv": 3} + return CurationModelTrainer( + labels=[[0, 1, 0, 1, 0, 1, 0, 1, 0, 1]], + folder=folder, + metric_names=metric_names, + imputation_strategies=imputation_strategies, + scaling_techniques=scaling_techniques, + classifiers=classifiers, + search_kwargs=search_kwargs, + ) + + +def make_temp_training_csv(): + """Create a temporary CSV file with artificially generated quality metrics. + The data is designed to be easy to dicern between units. Even units metric + values are all `0`, while odd units metric values are all `1`. + """ + with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp_file: + writer = csv.writer(temp_file) + writer.writerow(["unit_id", "metric1", "metric2", "metric3"]) + for i in range(5): + writer.writerow([i * 2, 0, 0, 0]) + writer.writerow([i * 2 + 1, 1, 1, 1]) + return temp_file.name + + +def test_load_and_preprocess_full(trainer): + """Check that we load and preprocess the csv file from `make_temp_training_csv` + correctly.""" + temp_file_path = make_temp_training_csv() + + # Load and preprocess the data from the temporary CSV file + trainer.load_and_preprocess_csv([temp_file_path]) + + # Assert that the data is loaded and preprocessed correctly + for a, row in trainer.X.iterrows(): + assert np.all(row.values == [float(a % 2)] * 3) + for a, label in enumerate(trainer.y.values): + assert label == a % 2 + for a, row in trainer.testing_metrics.iterrows(): + assert np.all(row.values == [a % 2] * 3) + assert row.name == a + + +def test_apply_scaling_imputation(trainer): + """Take a simple training and test set and check that they are corrected scaled, + using a standard scaler which rescales the training distribution to have mean 0 + and variance 1. Length between each row is 3, so if x0 is the first value in the + column, all other values are scaled as x -> 2/3(x - x0) - 1. The y (labled) values + do not get scaled.""" + + from sklearn.impute._knn import KNNImputer + from sklearn.preprocessing._data import StandardScaler + + imputation_strategy = "knn" + scaling_technique = "standard_scaler" + X_train = np.array([[1, 2, 3], [4, 5, 6]]) + X_test = np.array([[7, 8, 9], [10, 11, 12]]) + y_train = np.array([0, 1]) + y_test = np.array([2, 3]) + + X_train_scaled, X_test_scaled, y_train_scaled, y_test_scaled, imputer, scaler = trainer.apply_scaling_imputation( + imputation_strategy, scaling_technique, X_train, X_test, y_train, y_test + ) + + first_row_elements = X_train[0] + for a, row in enumerate(X_train): + assert np.all(2 / 3 * (row - first_row_elements) - 1.0 == X_train_scaled[a]) + for a, row in enumerate(X_test): + assert np.all(2 / 3 * (row - first_row_elements) - 1.0 == X_test_scaled[a]) + + assert np.all(y_train == y_train_scaled) + assert np.all(y_test == y_test_scaled) + + assert isinstance(imputer, KNNImputer) + assert isinstance(scaler, StandardScaler) + + +def test_get_classifier_search_space(trainer): + """For each classifier, there is a hyperparameter space we search over to find its + most accurate incarnation. Here, we check that we do indeed load the approprirate + dict of hyperparameter possibilities""" + + from sklearn.linear_model._logistic import LogisticRegression + + classifier = "LogisticRegression" + model, param_space = trainer.get_classifier_search_space(classifier) + + assert isinstance(model, LogisticRegression) + assert len(param_space) > 0 + assert isinstance(param_space, dict) + + +def test_get_custom_classifier_search_space(): + """Check that if a user passes a custom hyperparameter search space, that this is + passed correctly to the trainer.""" + + classifier = { + "LogisticRegression": { + "C": [0.1, 8.0], + "solver": ["lbfgs"], + "max_iter": [100, 400], + } + } + trainer = CurationModelTrainer(classifiers=classifier, labels=[[0, 1, 0, 1, 0, 1, 0, 1, 0, 1]]) + + model, param_space = trainer.get_classifier_search_space(list(classifier.keys())[0]) + assert param_space == classifier["LogisticRegression"] + + +def test_saved_files(trainer): + """During the trainer's creation, the following files should be created: + - best_model.skops + - labels.csv + - model_accuracies.csv + - model_info.json + - training_data.csv + This test checks that these exist, and checks some properties of the files.""" + + import pandas as pd + import json + + trainer.X = np.random.rand(10, 3) + trainer.y = np.append(np.ones(5), np.zeros(5)) + + trainer.evaluate_model_config() + trainer_folder = Path(trainer.folder) + + assert trainer_folder.is_dir() + + best_model_path = trainer_folder / "best_model.skops" + model_accuracies_path = trainer_folder / "model_accuracies.csv" + training_data_path = trainer_folder / "training_data.csv" + labels_path = trainer_folder / "labels.csv" + model_info_path = trainer_folder / "model_info.json" + + assert (best_model_path).is_file() + + model_accuracies = pd.read_csv(model_accuracies_path) + model_accuracies["classifier name"].values[0] == "LogisticRegression" + assert len(model_accuracies) == 1 + + training_data = pd.read_csv(training_data_path) + assert np.all(np.isclose(training_data.values[:, 1:4], trainer.X, rtol=1e-10)) + + labels = pd.read_csv(labels_path) + assert np.all(labels.values[:, 1] == trainer.y.astype("float")) + + model_info = pd.read_json(model_info_path) + + with open(model_info_path) as f: + model_info = json.load(f) + + assert set(model_info.keys()) == set(["metric_params", "requirements", "label_conversion"]) + + +def test_train_model(): + """A simple function test to check that `train_model` doesn't fail with one csv inputs""" + + metrics_path = make_temp_training_csv() + folder = tempfile.mkdtemp() + metric_names = ["metric1", "metric2", "metric3"] + trainer = train_model( + mode="csv", + metrics_paths=[metrics_path], + folder=folder, + labels=[[0, 1, 0, 1, 0, 1, 0, 1, 0, 1]], + metric_names=metric_names, + imputation_strategies=["median"], + scaling_techniques=["standard_scaler"], + classifiers=["LogisticRegression"], + overwrite=True, + search_kwargs={"cv": 3, "scoring": "balanced_accuracy", "n_iter": 1}, + ) + assert isinstance(trainer, CurationModelTrainer) + + +def test_train_model_using_two_csvs(): + """Models can be trained using more than one set of training data. This test checks + that `train_model` works with two inputs, from csv files.""" + + metrics_path_1 = make_temp_training_csv() + metrics_path_2 = make_temp_training_csv() + + folder = tempfile.mkdtemp() + metric_names = ["metric1", "metric2", "metric3"] + + trainer = train_model( + mode="csv", + metrics_paths=[metrics_path_1, metrics_path_2], + folder=folder, + labels=[[0, 1, 0, 1, 0, 1, 0, 1, 0, 1], [0, 1, 0, 1, 0, 1, 0, 1, 0, 1]], + metric_names=metric_names, + imputation_strategies=["median"], + scaling_techniques=["standard_scaler"], + classifiers=["LogisticRegression"], + overwrite=True, + ) + assert isinstance(trainer, CurationModelTrainer) + + +def test_train_using_two_sorting_analyzers(): + """Models can be trained using more than one set of training data. This test checks + that `train_model` works with two inputs, from sorting analzyers. It also checks that + an error is raised if the sorting_analyzers have different sets of metrics computed.""" + + sorting_analyzer_1 = make_sorting_analyzer() + sorting_analyzer_1.compute({"quality_metrics": {"metric_names": ["num_spikes", "snr"]}}) + + sorting_analyzer_2 = make_sorting_analyzer() + sorting_analyzer_2.compute({"quality_metrics": {"metric_names": ["num_spikes", "snr"]}}) + + labels_1 = [0, 1, 1, 1, 1] + labels_2 = [1, 1, 0, 1, 1] + + folder = tempfile.mkdtemp() + trainer = train_model( + analyzers=[sorting_analyzer_1, sorting_analyzer_2], + folder=folder, + labels=[labels_1, labels_2], + imputation_strategies=["median"], + scaling_techniques=["standard_scaler"], + classifiers=["LogisticRegression"], + overwrite=True, + ) + + assert isinstance(trainer, CurationModelTrainer) + + # Check that there is an error raised if the metric names are different + sorting_analyzer_2 = make_sorting_analyzer() + sorting_analyzer_2.compute({"quality_metrics": {"metric_names": ["num_spikes"], "delete_existing_metrics": True}}) + + with pytest.raises(Exception): + trainer = train_model( + analyzers=[sorting_analyzer_1, sorting_analyzer_2], + folder=folder, + labels=[labels_1, labels_2], + imputation_strategies=["median"], + scaling_techniques=["standard_scaler"], + classifiers=["LogisticRegression"], + overwrite=True, + ) + + # Now check that there is an error raised if we demand the same metric params, but don't have them + + sorting_analyzer_2.compute( + { + "quality_metrics": { + "metric_names": ["num_spikes", "snr"], + "metric_params": {"snr": {"peak_mode": "at_index"}}, + } + } + ) + + with pytest.raises(Exception): + train_model( + analyzers=[sorting_analyzer_1, sorting_analyzer_2], + folder=folder, + labels=[labels_1, labels_2], + imputation_strategies=["median"], + scaling_techniques=["standard_scaler"], + classifiers=["LogisticRegression"], + search_kwargs={"cv": 3, "scoring": "balanced_accuracy", "n_iter": 1}, + overwrite=True, + enforce_metric_params=True, + ) diff --git a/src/spikeinterface/curation/tests/trained_pipeline/best_model.skops b/src/spikeinterface/curation/tests/trained_pipeline/best_model.skops new file mode 100644 index 0000000000000000000000000000000000000000..362405f917e2f4c3dc41eca4160f2d8be69b1c32 GIT binary patch literal 34009 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 0: + warning_message = f"Parameters used to calculate {conflicting_metrics_between_1_2} are different for sorting_analyzers #{analyzer_index_1} and #{analyzer_index_2}" + if enforce_metric_params is True: + raise Exception(warning_message) + else: + warnings.warn(warning_message) + + unique_conflicting_metrics = set(conflicting_metrics) + return unique_conflicting_metrics + + def load_and_preprocess_csv(self, paths): + self._load_data_files(paths) + self.process_test_data_for_classification() + self.get_metric_params_csv() + + def get_metric_params_csv(self): + + from itertools import chain + + qm_metric_names = list(chain.from_iterable(qm_compute_name_to_column_names.values())) + tm_metric_names = list(chain.from_iterable(tm_compute_name_to_column_names.values())) + + quality_metric_names = [] + template_metric_names = [] + + for metric_name in self.metric_names: + if metric_name in qm_metric_names: + quality_metric_names.append(metric_name) + if metric_name in tm_metric_names: + template_metric_names.append(metric_name) + + self.metrics_params = {} + if quality_metric_names != {}: + self.metrics_params["quality_metric_params"] = {"metric_names": quality_metric_names} + if template_metric_names != {}: + self.metrics_params["template_metric_params"] = {"metric_names": template_metric_names} + + return + + def process_test_data_for_classification(self): + """ + Cleans the input data so that it can be used by sklearn. + + Extracts the target variable and features from the loaded dataset. + It handles string labels by converting them to integer codes and reindexes the + feature matrix to match the specified metrics list. Infinite values in the features + are replaced with NaN, and any remaining NaN values are filled with zeros. + + Raises + ------ + ValueError + If the target column specified is not found in the loaded dataset. + + Notes + ----- + If the target column contains string labels, a warning is issued and the labels + are converted to integer codes. The mapping from string labels to integer codes + is stored in the `label_conversion` attribute. + """ + + # Convert string labels to integer codes to allow classification + new_y = self.y.astype("category").cat.codes + self.label_conversion = dict(zip(new_y, self.y)) + self.y = new_y + + # Extract features + try: + if (set(self.metric_names) - set(self.testing_metrics.columns) != set()) and self.verbose is True: + print( + f"Dropped metrics (calculated but not included in metric_names): {set(self.testing_metrics.columns) - set(self.metric_names)}" + ) + self.X = self.testing_metrics[self.metric_names] + except KeyError as e: + raise KeyError(f"{str(e)}, metrics_list contains invalid metric names") + + self.X = self.testing_metrics.reindex(columns=self.metric_names) + self.X = _format_metric_dataframe(self.X) + + def apply_scaling_imputation(self, imputation_strategy, scaling_technique, X_train, X_test, y_train, y_test): + """Impute and scale the data using the specified techniques.""" + from sklearn.experimental import enable_iterative_imputer + from sklearn.impute import SimpleImputer, KNNImputer, IterativeImputer + from sklearn.ensemble import HistGradientBoostingRegressor + from sklearn.preprocessing import StandardScaler, MinMaxScaler, RobustScaler + + if imputation_strategy == "knn": + imputer = KNNImputer(n_neighbors=5) + elif imputation_strategy == "iterative": + imputer = IterativeImputer( + estimator=HistGradientBoostingRegressor(random_state=self.seed), random_state=self.seed + ) + else: + imputer = SimpleImputer(strategy=imputation_strategy) + + if scaling_technique == "standard_scaler": + scaler = StandardScaler() + elif scaling_technique == "min_max_scaler": + scaler = MinMaxScaler() + elif scaling_technique == "robust_scaler": + scaler = RobustScaler() + else: + raise ValueError( + f"Unknown scaling technique: {scaling_technique}. Supported scaling techniques are 'standard_scaler', 'min_max_scaler' and 'robust_scaler." + ) + + y_train_processed = y_train.astype(int) + y_test = y_test.astype(int) + + X_train_imputed = imputer.fit_transform(X_train) + X_test_imputed = imputer.transform(X_test) + X_train_processed = scaler.fit_transform(X_train_imputed) + X_test_processed = scaler.transform(X_test_imputed) + + # Apply SMOTE for class imbalance + if self.smote: + try: + from imblearn.over_sampling import SMOTE + except ModuleNotFoundError: + raise ModuleNotFoundError("Please install imbalanced-learn package to use SMOTE") + smote = SMOTE(random_state=self.seed) + X_train_processed, y_train_processed = smote.fit_resample(X_train_processed, y_train_processed) + + return X_train_processed, X_test_processed, y_train_processed, y_test, imputer, scaler + + def get_classifier_instance(self, classifier_name): + from sklearn.ensemble import RandomForestClassifier, AdaBoostClassifier, GradientBoostingClassifier + from sklearn.svm import SVC + from sklearn.linear_model import LogisticRegression + from sklearn.neural_network import MLPClassifier + + classifier_mapping = { + "RandomForestClassifier": RandomForestClassifier(random_state=self.seed), + "AdaBoostClassifier": AdaBoostClassifier(random_state=self.seed), + "GradientBoostingClassifier": GradientBoostingClassifier(random_state=self.seed), + "SVC": SVC(random_state=self.seed), + "LogisticRegression": LogisticRegression(random_state=self.seed), + "MLPClassifier": MLPClassifier(random_state=self.seed), + } + + # Check lightgbm package install + if classifier_name == "LGBMClassifier": + try: + import lightgbm + + self.requirements["lightgbm"] = lightgbm.__version__ + classifier_mapping["LGBMClassifier"] = lightgbm.LGBMClassifier(random_state=self.seed, verbose=-1) + except ImportError: + raise ImportError("Please install lightgbm package to use LGBMClassifier") + elif classifier_name == "CatBoostClassifier": + try: + import catboost + + self.requirements["catboost"] = catboost.__version__ + classifier_mapping["CatBoostClassifier"] = catboost.CatBoostClassifier( + silent=True, random_state=self.seed + ) + except ImportError: + raise ImportError("Please install catboost package to use CatBoostClassifier") + elif classifier_name == "XGBClassifier": + try: + import xgboost + + self.requirements["xgboost"] = xgboost.__version__ + classifier_mapping["XGBClassifier"] = xgboost.XGBClassifier( + use_label_encoder=False, random_state=self.seed + ) + except ImportError: + raise ImportError("Please install xgboost package to use XGBClassifier") + + if classifier_name not in classifier_mapping: + raise ValueError( + f"Unknown classifier: {classifier_name}. To see list of supported classifiers run\n\t>>> from spikeinterface.curation import get_default_classifier_search_spaces\n\t>>> print(get_default_classifier_search_spaces().keys())" + ) + + return classifier_mapping[classifier_name] + + def get_classifier_search_space(self, classifier_name): + + default_classifier_search_spaces = get_default_classifier_search_spaces() + + if classifier_name not in default_classifier_search_spaces: + raise ValueError( + f"Unknown classifier: {classifier_name}. To see list of supported classifiers run\n\t>>> from spikeinterface.curation import get_default_classifier_search_spaces\n\t>>> print(get_default_classifier_search_spaces().keys())" + ) + + model = self.get_classifier_instance(classifier_name) + if self.classifier_search_space is not None: + param_space = self.classifier_search_space[classifier_name] + else: + param_space = default_classifier_search_spaces[classifier_name] + return model, param_space + + def evaluate_model_config(self): + """ + Evaluates the model configurations with the given imputation strategies, scaling techniques, and classifiers. + + This method splits the preprocessed data into training and testing sets, then evaluates the specified + combinations of imputation strategies, scaling techniques, and classifiers. The evaluation results are + saved to the output folder. + + Raises + ------ + ValueError + If any of the specified classifier names are not recognized. + + Notes + ----- + The method converts the classifier names to actual classifier instances before evaluating them. + The evaluation results, including the best model and its parameters, are saved to the output folder. + """ + from sklearn.model_selection import train_test_split + + X_train, X_test, y_train, y_test = train_test_split( + self.X, self.y, test_size=self.test_size, random_state=self.seed, stratify=self.y + ) + classifier_instances = [self.get_classifier_instance(clf) for clf in self.classifiers] + self._evaluate( + self.imputation_strategies, + self.scaling_techniques, + classifier_instances, + X_train, + X_test, + y_train, + y_test, + self.search_kwargs, + ) + + def _load_data_files(self, paths): + import pandas as pd + + self.testing_metrics = pd.concat([pd.read_csv(path, index_col=0) for path in paths], axis=0) + + def _evaluate( + self, imputation_strategies, scaling_techniques, classifiers, X_train, X_test, y_train, y_test, search_kwargs + ): + from joblib import Parallel, delayed + from sklearn.pipeline import Pipeline + import pandas as pd + + results = Parallel(n_jobs=self.n_jobs)( + delayed(self._train_and_evaluate)( + imputation_strategy, scaler, classifier, X_train, X_test, y_train, y_test, idx, search_kwargs + ) + for idx, (imputation_strategy, scaler, classifier) in enumerate( + (imputation_strategy, scaler, classifier) + for imputation_strategy in imputation_strategies + for scaler in scaling_techniques + for classifier in classifiers + ) + ) + + test_accuracies, models = zip(*results) + + if self.search_kwargs is None or self.search_kwargs.get("scoring"): + scoring_method = "balanced_accuracy" + else: + scoring_method = self.search_kwargs.get("scoring") + + self.test_accuracies_df = pd.DataFrame(test_accuracies).sort_values(scoring_method, ascending=False) + + best_model_id = int(self.test_accuracies_df.iloc[0]["model_id"]) + best_model, best_imputer, best_scaler = models[best_model_id] + + best_pipeline = Pipeline( + [("imputer", best_imputer), ("scaler", best_scaler), ("classifier", best_model.best_estimator_)] + ) + + self.best_pipeline = best_pipeline + + if self.folder is not None: + self._save() + + def _save(self): + from skops.io import dump + import sklearn + import pandas as pd + + # export training data and labels + pd.DataFrame(self.X).to_csv(self.folder / f"training_data.csv", index_label="unit_id") + pd.DataFrame(self.y).to_csv(self.folder / f"labels.csv", index_label="unit_index") + + self.requirements["scikit-learn"] = sklearn.__version__ + + # Dump to skops if folder is provided + dump(self.best_pipeline, self.folder / f"best_model.skops") + self.test_accuracies_df.to_csv(self.folder / f"model_accuracies.csv", float_format="%.4f") + + model_info = {} + model_info["metric_params"] = self.metrics_params + + model_info["requirements"] = self.requirements + + model_info["label_conversion"] = self.label_conversion + + param_file = self.folder / "model_info.json" + Path(param_file).write_text(json.dumps(model_info, indent=4), encoding="utf8") + + def _train_and_evaluate( + self, imputation_strategy, scaler, classifier, X_train, X_test, y_train, y_test, model_id, search_kwargs + ): + from sklearn.metrics import balanced_accuracy_score, precision_score, recall_score + + search_kwargs = set_default_search_kwargs(search_kwargs) + + X_train_scaled, X_test_scaled, y_train, y_test, imputer, scaler = self.apply_scaling_imputation( + imputation_strategy, scaler, X_train, X_test, y_train, y_test + ) + if self.verbose is True: + print(f"Running {classifier.__class__.__name__} with imputation {imputation_strategy} and scaling {scaler}") + model, param_space = self.get_classifier_search_space(classifier.__class__.__name__) + + try: + from skopt import BayesSearchCV + + model = BayesSearchCV( + model, + param_space, + random_state=self.seed, + **search_kwargs, + ) + except: + if self.verbose is True: + print("BayesSearchCV from scikit-optimize not available, using RandomizedSearchCV") + from sklearn.model_selection import RandomizedSearchCV + + model = RandomizedSearchCV(model, param_space, **search_kwargs) + + model.fit(X_train_scaled, y_train) + y_pred = model.predict(X_test_scaled) + balanced_acc = balanced_accuracy_score(y_test, y_pred) + precision = precision_score(y_test, y_pred, average="macro") + recall = recall_score(y_test, y_pred, average="macro") + return { + "classifier name": classifier.__class__.__name__, + "imputation_strategy": imputation_strategy, + "scaling_strategy": scaler, + "balanced_accuracy": balanced_acc, + "precision": precision, + "recall": recall, + "model_id": model_id, + "best_params": model.best_params_, + }, (model, imputer, scaler) + + +def train_model( + mode="analyzers", + labels=None, + analyzers=None, + metrics_paths=None, + folder=None, + metric_names=None, + imputation_strategies=None, + scaling_techniques=None, + classifiers=None, + test_size=0.2, + overwrite=False, + seed=None, + search_kwargs=None, + verbose=True, + enforce_metric_params=False, + **job_kwargs, +): + """ + Trains and evaluates machine learning models for spike sorting curation. + + This function initializes a `CurationModelTrainer` object, loads and preprocesses the data, + and evaluates the specified combinations of imputation strategies, scaling techniques, and classifiers. + The evaluation results, including the best model and its parameters, are saved to the output folder. + + Parameters + ---------- + mode : "analyzers" | "csv", default: "analyzers" + Mode to use for training. + analyzers : list of SortingAnalyzer | None, default: None + List of SortingAnalyzer objects containing the quality metrics and labels to use for training, if using 'analyzers' mode. + labels : list of list | None, default: None + List of curated labels for each unit; must be in the same order as the metrics data. + metrics_paths : list of str or None, default: None + List of paths to the CSV files containing the metrics data if using 'csv' mode. + folder : str | None, default: None + The folder where outputs such as models and evaluation metrics will be saved. + metric_names : list of str | None, default: None + A list of metrics to use for training. If None, default metrics will be used. + imputation_strategies : list of str | None, default: None + A list of imputation strategies to try. Can be "knn”, "iterative" or any allowed + strategy passable to the sklearn `SimpleImputer`. If None, the default strategies + `["median", "most_frequent", "knn", "iterative"]` will be used. + scaling_techniques : list of str | None, default: None + A list of scaling techniques to try. Can be "standard_scaler", "min_max_scaler", + or "robust_scaler", If None, all techniques will be used. + classifiers : list of str | dict | None, default: None + A list of classifiers to evaluate. Optionally, a dictionary of classifiers and their hyperparameter search spaces can be provided. If None, default classifiers will be used. Check the `get_classifier_search_space` method for the default search spaces & format for custom spaces. + test_size : float, default: 0.2 + Proportion of the dataset to include in the test split, passed to `train_test_split` from `sklear`. + overwrite : bool, default: False + Overwrites the `folder` if it already exists + seed : int | None, default: None + Random seed for reproducibility. If None, a random seed will be generated. + search_kwargs : dict or None, default: None + Keyword arguments passed to `BayesSearchCV` or `RandomizedSearchCV` from `sklearn`. If None, use + `search_kwargs = {'cv': 3, 'scoring': 'balanced_accuracy', 'n_iter': 25}`. + verbose : bool, default: True + If True, useful information is printed during training. + enforce_metric_params : bool, default: False + If True and metric parameters used to calculate metrics for different `sorting_analyzer`s are + different, an error will be raised. + + + Returns + ------- + CurationModelTrainer + The `CurationModelTrainer` object used for training and evaluation. + + Notes + ----- + This function handles the entire workflow of initializing the trainer, loading and preprocessing the data, + and evaluating the models. The evaluation results are saved to the specified output folder. + """ + + if folder is None: + raise Exception("You must supply a folder for the model to be saved in using `folder='path/to/folder/'`") + + if overwrite is False: + assert not Path(folder).is_dir(), f"folder {folder} already exists, choose another name or use overwrite=True" + + if labels is None: + raise Exception("You must supply a list of lists of curated labels using `labels = [[...],[...],...]`") + + if mode not in ["analyzers", "csv"]: + raise Exception("`mode` must be equal to 'analyzers' or 'csv'.") + + if (test_size > 1.0) or (0.0 > test_size): + raise Exception("`test_size` must be between 0.0 and 1.0") + + trainer = CurationModelTrainer( + labels=labels, + folder=folder, + metric_names=metric_names, + imputation_strategies=imputation_strategies, + scaling_techniques=scaling_techniques, + classifiers=classifiers, + test_size=test_size, + seed=seed, + verbose=verbose, + search_kwargs=search_kwargs, + **job_kwargs, + ) + + if mode == "analyzers": + assert analyzers is not None, "Analyzers must be provided as a list for mode 'analyzers'" + trainer.load_and_preprocess_analyzers(analyzers, enforce_metric_params) + + elif mode == "csv": + for metrics_path in metrics_paths: + assert Path(metrics_path).is_file(), f"{metrics_path} is not a file." + trainer.load_and_preprocess_csv(metrics_paths) + + trainer.evaluate_model_config() + return trainer + + +def _get_computed_metrics(sorting_analyzer): + """Loads and organises the computed metrics from a sorting_analyzer into a single dataframe""" + + import pandas as pd + + quality_metrics, template_metrics = try_to_get_metrics_from_analyzer(sorting_analyzer) + calculated_metrics = pd.concat([quality_metrics, template_metrics], axis=1) + + # Remove any metrics for non-existent units, raise error if no units are present + calculated_metrics = calculated_metrics.loc[calculated_metrics.index.isin(sorting_analyzer.sorting.get_unit_ids())] + if calculated_metrics.shape[0] == 0: + raise ValueError("No units present in sorting data") + + return calculated_metrics + + +def try_to_get_metrics_from_analyzer(sorting_analyzer): + + extension_names = ["quality_metrics", "template_metrics"] + metric_extensions = [sorting_analyzer.get_extension(extension_name) for extension_name in extension_names] + + if any(metric_extensions) is False: + raise ValueError( + "At least one of quality metrics or template metrics must be computed before classification.", + "Compute both using `sorting_analyzer.compute('quality_metrics', 'template_metrics')", + ) + + metric_extensions_data = [] + for metric_extension in metric_extensions: + try: + metric_extensions_data.append(metric_extension.get_data()) + except: + metric_extensions_data.append(None) + + return metric_extensions_data + + +def set_default_search_kwargs(search_kwargs): + + if search_kwargs is None: + search_kwargs = {} + + if search_kwargs.get("cv") is None: + search_kwargs["cv"] = 5 + if search_kwargs.get("scoring") is None: + search_kwargs["scoring"] = "balanced_accuracy" + if search_kwargs.get("n_iter") is None: + search_kwargs["n_iter"] = 25 + + return search_kwargs + + +def check_metric_names_are_the_same(metrics_for_each_analyzer): + """ + Given a list of dataframes, checks that the keys are all equal. + """ + + for i, metrics_for_analyzer_1 in enumerate(metrics_for_each_analyzer): + for j, metrics_for_analyzer_2 in enumerate(metrics_for_each_analyzer): + if i > j: + metric_names_1 = set(metrics_for_analyzer_1.keys()) + metric_names_2 = set(metrics_for_analyzer_2.keys()) + if metric_names_1 != metric_names_2: + metrics_in_1_but_not_2 = metric_names_1.difference(metric_names_2) + metrics_in_2_but_not_1 = metric_names_2.difference(metric_names_1) + + error_message = f"Computed metrics are not equal for sorting_analyzers #{j} and #{i}\n" + if metrics_in_1_but_not_2: + error_message += f"#{j} does not contain {metrics_in_1_but_not_2}, which #{i} does." + if metrics_in_2_but_not_1: + error_message += f"#{i} does not contain {metrics_in_2_but_not_1}, which #{j} does." + raise Exception(error_message) + + +def _format_metric_dataframe(input_data): + + input_data = input_data.map(lambda x: np.nan if np.isinf(x) else x) + input_data = input_data.astype("float32") + + return input_data diff --git a/src/spikeinterface/qualitymetrics/pca_metrics.py b/src/spikeinterface/qualitymetrics/pca_metrics.py index ca21f1e45f..c789d1af82 100644 --- a/src/spikeinterface/qualitymetrics/pca_metrics.py +++ b/src/spikeinterface/qualitymetrics/pca_metrics.py @@ -42,6 +42,9 @@ max_spikes=10000, min_spikes=10, min_fr=0.0, n_neighbors=4, n_components=10, radius_um=100, peak_sign="neg" ), silhouette=dict(method=("simplified",)), + isolation_distance=dict(), + l_ratio=dict(), + d_prime=dict(), )