active-units 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ """
2
+ Active Units: Interactive unit filtering by activity response.
3
+
4
+ Minimal, flat-structure package for computing and filtering neural units
5
+ based on baseline/response firing rates.
6
+
7
+ Three inputs:
8
+ 1. sorted_spikes_dir: Path to spike sorting output folder
9
+ 2. trials_csv: Path to trials.csv (columns: experiment, trial, sample)
10
+ 3. config: Path to config.json or dict with activity_filter parameters
11
+
12
+ Example:
13
+ >>> from active_units import compute_activity
14
+ >>> summary = compute_activity(
15
+ ... sorted_spikes_dir="/path/to/sorted_spikes",
16
+ ... trials_csv="/path/to/trials.csv",
17
+ ... config="/path/to/config.json",
18
+ ... output_dir="/path/to/output"
19
+ ... )
20
+ """
21
+
22
+ __version__ = "0.1.0"
23
+ __author__ = "Lab Team"
24
+
25
+ from .activity import compute_activity
26
+ from .dashboard import make_app
27
+ from .utils import load_config, load_trials
28
+
29
+ __all__ = [
30
+ "compute_activity",
31
+ "make_app",
32
+ "load_config",
33
+ "load_trials",
34
+ ]
@@ -0,0 +1,228 @@
1
+ """Compute unit activity metrics from kilosort output."""
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+ from pathlib import Path
6
+ from typing import Union, Dict, Any, Tuple
7
+ from scipy import stats
8
+
9
+ from .utils import load_config, load_trials, validate_sorted_spikes_dir
10
+
11
+
12
+ def compute_activity(
13
+ sorted_spikes_dir: str,
14
+ trials_csv: str,
15
+ config: Union[str, Dict[str, Any]],
16
+ output_dir: str = None,
17
+ verbose: bool = False,
18
+ ) -> pd.DataFrame:
19
+ """
20
+ Compute baseline/response activity for all units.
21
+
22
+ Loads spike data from spike sorting output, samples random trials per experiment,
23
+ calculates baseline and response firing rates, performs paired t-tests.
24
+
25
+ Args:
26
+ sorted_spikes_dir: Path to spike sorting output folder with spike_times.npy,
27
+ spike_clusters.npy, cluster_group.tsv
28
+ (works with kilosort4, kilosort3, IBL pipeline, etc.)
29
+ trials_csv: Path to trials.csv with columns: experiment, trial, sample
30
+ config: Path to config.json or dict with activity_filter parameters
31
+ Required keys: baseline_window_start/end, response_window_start/end,
32
+ n_random_trials, sample_rate
33
+ output_dir: Directory to save activity_summary.csv (default: current dir)
34
+ verbose: Print diagnostic info
35
+
36
+ Returns:
37
+ pd.DataFrame: Summary statistics with columns:
38
+ experiment, unit, bsl_mean, bsl_std, signal_mean,
39
+ signal_std, difference, p_value
40
+
41
+ Saves:
42
+ - activity_summary.csv: Activity summary per unit per experiment
43
+ - clusters_included.txt: List of cluster IDs that were included
44
+
45
+ Example:
46
+ >>> summary = compute_activity(
47
+ ... sorted_spikes_dir="/path/to/sorted_spikes",
48
+ ... trials_csv="/path/to/trials.csv",
49
+ ... config="/path/to/config.json",
50
+ ... output_dir="/tmp/results",
51
+ ... verbose=True
52
+ ... )
53
+ >>> print(summary[['experiment', 'unit', 'bsl_mean', 'signal_mean']])
54
+ """
55
+
56
+ # Load configuration
57
+ if verbose:
58
+ print("Loading configuration...")
59
+ config_dict = load_config(config)
60
+
61
+ baseline_start_s = config_dict["baseline_window_start"]
62
+ baseline_end_s = config_dict["baseline_window_end"]
63
+ response_start_s = config_dict["response_window_start"]
64
+ response_end_s = config_dict["response_window_end"]
65
+ n_random = config_dict["n_random_trials"]
66
+ sample_rate = config_dict["sample_rate"]
67
+
68
+ # Load trials
69
+ if verbose:
70
+ print("Loading trials...")
71
+ trials_df = load_trials(trials_csv)
72
+
73
+ # Validate sorted spikes directory
74
+ if verbose:
75
+ print("Validating spike sorting output directory...")
76
+ ss_files = validate_sorted_spikes_dir(sorted_spikes_dir)
77
+
78
+ # Load spike data
79
+ if verbose:
80
+ print("Loading spike data...")
81
+ spike_times = np.load(ss_files["spike_times"]).flatten()
82
+ spike_clusters = np.load(ss_files["spike_clusters"]).flatten()
83
+
84
+ # Load or create cluster_group
85
+ if ss_files["cluster_group"] and Path(ss_files["cluster_group"]).exists():
86
+ cluster_group = pd.read_csv(ss_files["cluster_group"], sep="\t")
87
+ valid_mask = cluster_group["group"].isin(["good", "mua"])
88
+ valid_units = cluster_group[valid_mask]["cluster_id"].values
89
+ else:
90
+ # If no cluster_group, use all unique cluster IDs
91
+ valid_units = np.unique(spike_clusters)
92
+ if verbose:
93
+ print(" (cluster_group.tsv not found, using all units)")
94
+
95
+ if verbose:
96
+ print(f" {len(valid_units)} valid units")
97
+ print(f" {len(spike_times)} total spikes")
98
+
99
+ # Convert time windows from seconds to samples
100
+ baseline_start_samples = int(baseline_start_s * sample_rate)
101
+ baseline_end_samples = int(baseline_end_s * sample_rate)
102
+ response_start_samples = int(response_start_s * sample_rate)
103
+ response_end_samples = int(response_end_s * sample_rate)
104
+
105
+ baseline_duration = (baseline_end_samples - baseline_start_samples) / sample_rate
106
+ response_duration = (response_end_samples - response_start_samples) / sample_rate
107
+
108
+ if verbose:
109
+ print(f" Baseline window: {baseline_start_s:.2f} to {baseline_end_s:.2f}s ({baseline_duration:.2f}s)")
110
+ print(f" Response window: {response_start_s:.2f} to {response_end_s:.2f}s ({response_duration:.2f}s)")
111
+
112
+ # Results
113
+ summary_data = []
114
+
115
+ # Process each experiment
116
+ experiments = sorted(trials_df["experiment"].unique())
117
+
118
+ if verbose:
119
+ print(f"\nProcessing {len(experiments)} experiments...")
120
+
121
+ for exp_id in experiments:
122
+ exp_trials = trials_df[trials_df["experiment"] == exp_id]
123
+
124
+ if verbose:
125
+ print(f" Experiment {exp_id}: {len(exp_trials)} trials")
126
+
127
+ # Sample random trials
128
+ if len(exp_trials) > n_random:
129
+ sampled_trials = exp_trials.sample(n=n_random, random_state=int(exp_id))
130
+ else:
131
+ sampled_trials = exp_trials
132
+
133
+ if verbose:
134
+ print(f" Sampling {len(sampled_trials)} trials for activity estimation")
135
+
136
+ # For each valid unit
137
+ for unit_id in valid_units:
138
+ unit_mask = spike_clusters == unit_id
139
+ unit_spikes = spike_times[unit_mask]
140
+
141
+ baseline_rates = []
142
+ response_rates = []
143
+
144
+ # Process each trial
145
+ for _, trial_row in sampled_trials.iterrows():
146
+ trial_sample = int(trial_row["sample"])
147
+
148
+ # Baseline period
149
+ baseline_start_sample = trial_sample + baseline_start_samples
150
+ baseline_end_sample = trial_sample + baseline_end_samples
151
+ baseline_spikes = unit_spikes[
152
+ (unit_spikes >= baseline_start_sample) &
153
+ (unit_spikes < baseline_end_sample)
154
+ ]
155
+ baseline_rate = len(baseline_spikes) / baseline_duration if baseline_duration > 0 else 0
156
+ baseline_rates.append(baseline_rate)
157
+
158
+ # Response period
159
+ response_start_sample = trial_sample + response_start_samples
160
+ response_end_sample = trial_sample + response_end_samples
161
+ response_spikes = unit_spikes[
162
+ (unit_spikes >= response_start_sample) &
163
+ (unit_spikes < response_end_sample)
164
+ ]
165
+ response_rate = len(response_spikes) / response_duration if response_duration > 0 else 0
166
+ response_rates.append(response_rate)
167
+
168
+ # Calculate statistics
169
+ baseline_rates = np.array(baseline_rates)
170
+ response_rates = np.array(response_rates)
171
+
172
+ bsl_mean = np.mean(baseline_rates)
173
+ bsl_std = np.std(baseline_rates)
174
+ sig_mean = np.mean(response_rates)
175
+ sig_std = np.std(response_rates)
176
+ difference = sig_mean - bsl_mean
177
+
178
+ # Paired t-test (baseline vs response for same trials)
179
+ if len(baseline_rates) > 1:
180
+ t_stat, p_value = stats.ttest_rel(response_rates, baseline_rates)
181
+ else:
182
+ p_value = np.nan
183
+
184
+ summary_data.append({
185
+ "experiment": exp_id,
186
+ "unit": unit_id,
187
+ "bsl_mean": bsl_mean,
188
+ "bsl_std": bsl_std,
189
+ "signal_mean": sig_mean,
190
+ "signal_std": sig_std,
191
+ "difference": difference,
192
+ "p_value": p_value,
193
+ })
194
+
195
+ if verbose:
196
+ print(f" ✓ Processed {len(valid_units)} units")
197
+
198
+ # Create summary dataframe
199
+ summary_df = pd.DataFrame(summary_data)
200
+
201
+ # Round for display
202
+ for col in ["bsl_mean", "bsl_std", "signal_mean", "signal_std", "difference"]:
203
+ summary_df[col] = summary_df[col].round(2)
204
+ summary_df["p_value"] = summary_df["p_value"].round(4)
205
+
206
+ # Save results
207
+ if output_dir is None:
208
+ output_dir = "."
209
+
210
+ output_path = Path(output_dir)
211
+ output_path.mkdir(parents=True, exist_ok=True)
212
+
213
+ summary_file = output_path / "activity_summary.csv"
214
+ summary_df.to_csv(summary_file, index=False)
215
+
216
+ clusters_file = output_path / "clusters_included.txt"
217
+ with open(clusters_file, "w") as f:
218
+ for unit_id in sorted(valid_units):
219
+ f.write(f"{unit_id}\n")
220
+
221
+ if verbose:
222
+ print(f"\n✓ Summary saved to: {summary_file}")
223
+ print(f" Total rows: {len(summary_df)}")
224
+ print(f" Unique units: {summary_df['unit'].nunique()}")
225
+ print(f" Unique experiments: {summary_df['experiment'].nunique()}")
226
+ print(f" Clusters included: {len(valid_units)}")
227
+
228
+ return summary_df
active_units/cli.py ADDED
@@ -0,0 +1,148 @@
1
+ """Command-line interface for active-units."""
2
+
3
+ import click
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from .activity import compute_activity
8
+ from .dashboard import make_app
9
+
10
+
11
+ @click.group()
12
+ def cli():
13
+ """Active Units — compute and filter neural units interactively."""
14
+ pass
15
+
16
+
17
+ @cli.command()
18
+ @click.argument('sorted_spikes_dir', type=click.Path(exists=True))
19
+ @click.argument('trials_csv', type=click.Path(exists=True))
20
+ @click.argument('config', type=click.Path(exists=True))
21
+ @click.option('--output-dir', type=click.Path(), default=None,
22
+ help='Output directory (default: current directory)')
23
+ @click.option('--verbose', is_flag=True, help='Print diagnostic info')
24
+ def compute(sorted_spikes_dir, trials_csv, config, output_dir, verbose):
25
+ """
26
+ Compute baseline/response activity for all units.
27
+
28
+ SORTED_SPIKES_DIR: Path to spike sorting output folder
29
+ (works with kilosort4, kilosort3, IBL pipeline, etc.)
30
+ TRIALS_CSV: Path to trials.csv (columns: experiment, trial, sample)
31
+ CONFIG: Path to config.json (activity_filter parameters)
32
+
33
+ Example:
34
+ active-units compute /path/to/sorted_spikes trials.csv config.json --output-dir ./results
35
+ """
36
+ try:
37
+ summary = compute_activity(
38
+ sorted_spikes_dir=sorted_spikes_dir,
39
+ trials_csv=trials_csv,
40
+ config=config,
41
+ output_dir=output_dir,
42
+ verbose=verbose
43
+ )
44
+
45
+ if verbose:
46
+ print("\nActivity Summary Statistics:")
47
+ print(summary[['experiment', 'unit', 'bsl_mean', 'signal_mean', 'difference', 'p_value']].head(10))
48
+
49
+ except Exception as e:
50
+ click.secho(f"Error: {e}", fg='red')
51
+ sys.exit(1)
52
+
53
+
54
+ @cli.command()
55
+ @click.argument('activity_csv', type=click.Path(exists=True))
56
+ @click.argument('trials_csv', type=click.Path(exists=True))
57
+ @click.argument('sorted_spikes_dir', type=click.Path(exists=True))
58
+ @click.argument('config', type=click.Path(exists=True))
59
+ @click.option('--port', type=int, default=5006, help='Bokeh server port (default: 5006)')
60
+ @click.option('--show', is_flag=True, help='Open in browser')
61
+ def dashboard(activity_csv, trials_csv, sorted_spikes_dir, config, port, show):
62
+ """
63
+ Launch interactive filtering dashboard.
64
+
65
+ ACTIVITY_CSV: Path to activity_summary.csv (output from compute command)
66
+ TRIALS_CSV: Path to trials.csv
67
+ SORTED_SPIKES_DIR: Path to spike sorting output folder
68
+ CONFIG: Path to config.json
69
+
70
+ Example:
71
+ active-units dashboard results/activity_summary.csv trials.csv /path/to/sorted_spikes config.json --port 5006 --show
72
+ """
73
+ try:
74
+ from bokeh.server.server import Server
75
+ from bokeh.plotting import curdoc
76
+
77
+ def bkapp(doc):
78
+ layout = make_app(
79
+ activity_csv=activity_csv,
80
+ trials_csv=trials_csv,
81
+ sorted_spikes_dir=sorted_spikes_dir,
82
+ config=config,
83
+ )
84
+ doc.add_root(layout)
85
+ doc.title = "Unit Activity Filter"
86
+
87
+ server = Server(
88
+ {'/': bkapp},
89
+ port=port,
90
+ show=show,
91
+ allow_websocket_origin=['localhost:' + str(port)]
92
+ )
93
+
94
+ click.secho(f"✓ Dashboard running at http://localhost:{port}", fg='green')
95
+ click.echo("Press Ctrl+C to stop")
96
+
97
+ server.start()
98
+ server.io_loop.start()
99
+
100
+ except Exception as e:
101
+ click.secho(f"Error: {e}", fg='red')
102
+ sys.exit(1)
103
+
104
+
105
+ @cli.command()
106
+ @click.argument('sorted_spikes_dir', type=click.Path(exists=True))
107
+ @click.argument('trials_csv', type=click.Path(exists=True))
108
+ def status(sorted_spikes_dir, trials_csv):
109
+ """
110
+ Check setup and data integrity.
111
+
112
+ SORTED_SPIKES_DIR: Path to spike sorting output folder
113
+ TRIALS_CSV: Path to trials.csv
114
+
115
+ Validates that all required files exist and have expected structure.
116
+ """
117
+ try:
118
+ from .utils import validate_sorted_spikes_dir, load_trials
119
+
120
+ click.echo("Checking spike sorting output directory...")
121
+ ss_files = validate_sorted_spikes_dir(sorted_spikes_dir)
122
+ click.secho(" ✓ Spike sorting files found", fg='green')
123
+
124
+ click.echo("Loading spike data...")
125
+ import numpy as np
126
+ spike_times = np.load(ss_files["spike_times"]).flatten()
127
+ spike_clusters = np.load(ss_files["spike_clusters"]).flatten()
128
+ click.secho(f" ✓ {len(spike_times)} spikes from {len(np.unique(spike_clusters))} clusters", fg='green')
129
+
130
+ click.echo("Loading trials...")
131
+ trials = load_trials(trials_csv)
132
+ click.secho(f" ✓ {len(trials)} trials across {trials['experiment'].nunique()} experiments", fg='green')
133
+
134
+ click.echo("\n" + "=" * 50)
135
+ click.secho("Setup looks good!", fg='green')
136
+
137
+ except Exception as e:
138
+ click.secho(f"Error: {e}", fg='red')
139
+ sys.exit(1)
140
+
141
+
142
+ def main():
143
+ """Entry point for CLI."""
144
+ cli()
145
+
146
+
147
+ if __name__ == '__main__':
148
+ main()