-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathMMN_MSfigs.py
More file actions
6213 lines (5284 loc) · 254 KB
/
Copy pathMMN_MSfigs.py
File metadata and controls
6213 lines (5284 loc) · 254 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MMN_MSfigs.py - Main analysis script for the Optimum-1 MMN study
Generates all figures and statistical analyses for the manuscript:
- Figure 2: Dataset overview (unit counts, firing rates, participant distribution)
- Figure 3: General deviance detection (combined deviant, epoch-based LME)
- Figure 4: Feature-category deviance detection (epoch-based LME)
- Figure 5: Time-resolved LME analysis (ΔAIC + t-values, Amy & HPC)
- Figure 6: TTG & Ins individual unit responses
- Supplementary figures: Individual deviants, carryover, SDF panels, heatmaps
Usage:
1. Set `dataPath` to point to the Optimum1_ProcessedData/ directory
2. Run sections sequentially (designed for interactive use in IDE with cell execution)
3. Results are saved to Figures/ directory
Dependencies: MMN_funcs.py, MMN_stathelp.py
"""
# =============================================================================
# IMPORTS
# =============================================================================
from MMN_funcs import (
buildSdfDF_mmn, create_mmn_colormap_for_plots, fit_glm_model,
compute_model_metrics, remove_sdf_outlier_trials, prepare_mmn_data_for_glm,get_smoothed_data2
)
import numpy as np
import matplotlib.pyplot as plt
from scipy.io import loadmat
from matplotlib.patches import Rectangle
import pandas as pd
import seaborn as sns
import os
from statsmodels.stats.multitest import multipletests
import copy
from pathlib import Path
import time
from pymatreader import read_mat
from joblib import Parallel, delayed
import statsmodels.api as sm
import statsmodels.formula.api as smf
from scipy.stats import binomtest, combine_pvalues, mannwhitneyu, fisher_exact, kruskal
import pickle
import gc
def add_contrast_columns(data, contrast_type):
"""
Add necessary contrast columns to the data based on contrast type.
Parameters:
-----------
data : pandas.DataFrame
DataFrame to modify in-place
contrast_type : str
Type of contrast to add columns for
"""
# Define categories and deviants
deviant_categories = {
'Location': ['LocLeft', 'LocRight'],
'Intensity': ['intensityDown', 'intensityUp'],
'Frequency': ['Freq_450', 'Freq_550'],
'Timing': ['Dur25', 'Gap']
}
all_deviants = ['Std', 'LocLeft', 'LocRight', 'intensityDown', 'intensityUp',
'Freq_450', 'Freq_550', 'Dur25', 'Gap']
# Add standard/deviant flag
if 'isDeviant' not in data.columns:
data['isDeviant'] = data['eventID'] != 'Std'
# Add category flags
for category, deviants in deviant_categories.items():
column_name = f'is{category}'
if column_name not in data.columns:
data[column_name] = data['eventID'].isin(deviants)
# Add individual deviant flags
for deviant in all_deviants:
column_name = f'is{deviant}'
if column_name not in data.columns:
data[column_name] = data['eventID'] == deviant
# For 'categories' contrast, create DeviantCategory
if contrast_type == 'categories':
if 'DeviantCategory' not in data.columns:
data['DeviantCategory'] = 'Standard'
for category in deviant_categories:
mask = data['eventID'].isin(deviant_categories[category])
data.loc[mask, 'DeviantCategory'] = category
# For 'individual' contrast, create DeviantType
if contrast_type == 'individual':
if 'DeviantType' not in data.columns:
data['DeviantType'] = data['eventID']
def raincloud_plot(x, y, data, ax=None, palette=None, orient='v',
violin_side='right', jitter=0.1, point_size=3,
show_box=True, box_width=0.15, point_style='contrast',
point_shift=0.15, point_hue=None, point_palette=None,
point_alpha=0.7, **kwargs):
"""
Create a complete raincloud plot combining half-violin, swarm, and boxplot
Parameters:
-----------
x, y : str
Column names for categorical and continuous variables
data : DataFrame
Input data
ax : matplotlib axis
Axis to plot on
palette : str or list
Color palette for different categories (violin colors)
orient : str
'v' for vertical, 'h' for horizontal
violin_side : str
Which side of violin to show ('left', 'right', 'top', 'bottom')
jitter : float
Amount of jitter for strip plot
point_size : float
Size of individual data points
show_box : bool
Whether to show boxplot
box_width : float
Width of boxplot
point_style : str
How to style points: 'contrast' (darker), 'edge' (black edges),
'shift' (moved away from violin), 'white' (white with edges), 'hue' (colored by point_hue)
point_shift : float
How much to shift points away from violin (when point_style='shift')
point_hue : str or None
Column name to use for coloring individual points (categorical or boolean)
point_palette : str, list, or dict
Color palette for point_hue. Can be seaborn palette name, list of colors,
or dict mapping point_hue values to colors
point_alpha : float
Alpha (transparency) for data points
**kwargs : additional arguments
Passed to violin plot
"""
if ax is None:
fig, ax = plt.subplots(figsize=(10, 6))
# Create violin plot with seaborn first
violin_ax = sns.violinplot(x=x, y=y, data=data, ax=ax,
inner=None, hue=x, palette=palette, legend=False, **kwargs)
# Convert to half violins by clipping
for item in violin_ax.collections:
# Get the bounding box of each violin
bbox = item.get_paths()[0].get_extents()
x0, y0, width, height = bbox.x0, bbox.y0, bbox.width, bbox.height
if orient == 'v': # Vertical orientation
if violin_side == 'right':
# Keep right half
clip_box = Rectangle((x0 + width/2, y0), width/2, height,
transform=ax.transData)
elif violin_side == 'left':
# Keep left half
clip_box = Rectangle((x0, y0), width/2, height,
transform=ax.transData)
else: # Horizontal orientation
if violin_side == 'top':
# Keep top half
clip_box = Rectangle((x0, y0 + height/2), width, height/2,
transform=ax.transData)
elif violin_side == 'bottom':
# Keep bottom half
clip_box = Rectangle((x0, y0), width, height/2,
transform=ax.transData)
item.set_clip_path(clip_box)
# Prepare point styling based on chosen style
# Determine if we're using hue coloring (can be combined with any point_style)
use_hue_coloring = point_hue is not None
if use_hue_coloring:
# Use point_hue for coloring - will be handled in plotting section
point_edgecolor = 'black' if point_hue in data.columns and len(data[point_hue].unique()) > 2 else None
# Set up point palette
if point_palette is None:
# Default palettes based on data type
unique_vals = data[point_hue].unique()
if len(unique_vals) == 2:
# Binary/boolean - use contrasting colors
if set(unique_vals).issubset({True, False, 0, 1, 'True', 'False'}):
final_point_palette = ['lightcoral', 'lightblue']
else:
final_point_palette = ['orange', 'purple']
else:
# Multiple categories - use seaborn palette
final_point_palette = sns.color_palette("Set1", len(unique_vals))
elif isinstance(point_palette, str):
# Seaborn palette name
final_point_palette = sns.color_palette(point_palette, len(data[point_hue].unique()))
elif isinstance(point_palette, dict):
# Dictionary mapping
final_point_palette = point_palette
else:
# List of colors
final_point_palette = point_palette
# Apply point_style-specific settings (these can work with or without hue coloring)
if point_style == 'contrast' and not use_hue_coloring:
# Make points darker than violins
if isinstance(palette, str):
temp_palette = sns.color_palette(palette, n_colors=len(data[x].unique()))
final_point_palette = [tuple(np.array(color) * 0.7) for color in temp_palette]
else:
final_point_palette = palette
point_edgecolor = None
elif point_style == 'edge' and not use_hue_coloring:
# Same color as violin but with black edges
final_point_palette = palette
point_edgecolor = 'black'
elif point_style == 'white' and not use_hue_coloring:
# White points with black edges
final_point_palette = 'white'
point_edgecolor = 'black'
elif point_style == 'shift' and not use_hue_coloring:
# Darker points, will be shifted
final_point_palette = 'black'
point_edgecolor = None
# If no specific styling and no hue, use default
if not use_hue_coloring and point_style not in ['contrast', 'edge', 'white', 'shift']:
final_point_palette = 'black'
point_edgecolor = None
# Add strip plot (jittered points) with custom positioning and coloring
if point_style == 'shift' or use_hue_coloring:
# Manual positioning for shifted points or hue coloring (or both)
categories = data[x].unique()
for i, cat in enumerate(categories):
cat_data = data[data[x] == cat]
y_values = cat_data[y].values
# Calculate shift direction based on violin side (only if point_style is 'shift')
if orient == 'v':
if violin_side == 'right' and point_style == 'shift':
x_pos = i - point_shift # Shift left
elif violin_side == 'left' and point_style == 'shift':
x_pos = i + point_shift # Shift right
else:
x_pos = i # No shift
# Add jitter
x_jittered = x_pos + np.random.uniform(-jitter, jitter, len(y_values))
if use_hue_coloring:
# Color points by hue variable
hue_values = cat_data[point_hue].values
unique_hue_vals = sorted(data[point_hue].unique())
for hue_val in unique_hue_vals:
mask = hue_values == hue_val
if np.any(mask):
if isinstance(final_point_palette, dict):
color = final_point_palette[hue_val]
else:
hue_idx = unique_hue_vals.index(hue_val)
color = final_point_palette[hue_idx]
ax.scatter(x_jittered[mask], y_values[mask], s=point_size**2,
color=color, alpha=point_alpha, label=f'{hue_val}' if i == 0 else "",
edgecolors=point_edgecolor, linewidth=0.5)
else:
# Single color (for shift style without hue)
if isinstance(final_point_palette, list) and len(final_point_palette) > i:
color = final_point_palette[i]
else:
color = final_point_palette
ax.scatter(x_jittered, y_values, s=point_size**2,
color=color, alpha=point_alpha,
edgecolors=point_edgecolor, linewidth=0.5)
else: # Horizontal orientation
if violin_side == 'top' and point_style == 'shift':
y_pos = i - point_shift # Shift down
elif violin_side == 'bottom' and point_style == 'shift':
y_pos = i + point_shift # Shift up
else:
y_pos = i # No shift
# Add jitter
y_jittered = y_pos + np.random.uniform(-jitter, jitter, len(y_values))
if use_hue_coloring:
# Color points by hue variable
hue_values = cat_data[point_hue].values
unique_hue_vals = sorted(data[point_hue].unique())
for hue_val in unique_hue_vals:
mask = hue_values == hue_val
if np.any(mask):
if isinstance(final_point_palette, dict):
color = final_point_palette[hue_val]
else:
hue_idx = unique_hue_vals.index(hue_val)
color = final_point_palette[hue_idx]
ax.scatter(y_values[mask], y_jittered[mask], s=point_size**2,
color=color, alpha=point_alpha, label=f'{hue_val}' if i == 0 else "",
edgecolors=point_edgecolor, linewidth=0.5)
else:
# Single color (for shift style without hue)
if isinstance(final_point_palette, list) and len(final_point_palette) > i:
color = final_point_palette[i]
else:
color = final_point_palette
ax.scatter(y_values, y_jittered, s=point_size**2,
color=color, alpha=point_alpha,
edgecolors=point_edgecolor, linewidth=0.5)
else:
# Use seaborn stripplot for other styles
strip_kwargs = {'jitter': jitter, 'size': point_size, 'alpha': point_alpha}
if point_edgecolor:
strip_kwargs['edgecolor'] = point_edgecolor
strip_kwargs['linewidth'] = 0.5
if orient == 'v':
sns.stripplot(x=x, y=y, data=data, ax=ax,
palette=final_point_palette, **strip_kwargs)
else:
sns.stripplot(x=y, y=x, data=data, ax=ax,
palette=final_point_palette, **strip_kwargs)
# Add legend for point hue if used
if use_hue_coloring and point_hue is not None:
# Only add legend if we created labels (i.e., for the first category)
handles, labels = ax.get_legend_handles_labels()
if handles:
ax.legend(handles, labels, title=point_hue, loc='best')
# Add boxplot if requested
if show_box:
if orient == 'v':
sns.boxplot(x=x, y=y, data=data, ax=ax,
width=box_width, hue=x, palette=palette, legend=False,
boxprops={'facecolor': 'none', 'edgecolor': 'black'},
whiskerprops={'color': 'black'},
capprops={'color': 'black'},
medianprops={'color': 'red', 'linewidth': 2})
else:
sns.boxplot(x=y, y=x, data=data, ax=ax,
width=box_width, hue=x, palette=palette, legend=False,
boxprops={'facecolor': 'none', 'edgecolor': 'black'},
whiskerprops={'color': 'black'},
capprops={'color': 'black'},
medianprops={'color': 'red', 'linewidth': 2})
return ax
def unitOverview(df_work, save_fig=True, output_dir=None, figsize=(160/25.4, 100/25.4),didMap = None):
"""
Generate comprehensive unit overview including counts, firing rate, and participant/session analysis.
Parameters:
-----------
dfIn : pandas.DataFrame
One of three DataFrame types:
- DF_Units: Unit-level data (one row per unit) with meanFR column
- DF_FRall: Trial-level FR data with meanFR column
- DF_sdfs: Trial-level SDF data with SpikeDensity column
Should have: UniqueLabel, BrainRegion (or Bregion), SU, ID
save_fig : bool
Whether to save the figure
output_dir : str or Path
Directory to save figure
figsize : tuple
Figure size
Returns:
--------
dict
Dictionary containing summary tables and figure
"""
# Initialize variables early to avoid undefined variable errors
is_unit_level = False
data_type = "Unknown"
# Detect DataFrame type for appropriate processing
if 'meanFR' in df_work.columns and len(df_work) == df_work['UniqueLabel'].nunique():
is_unit_level = True
data_type = "Unit-level (DF_Units)"
elif 'meanFR' in df_work.columns:
is_unit_level = False
data_type = "Trial-level FR (DF_FRall)"
elif 'SpikeDensity' in df_work.columns:
is_unit_level = False
data_type = "Trial-level SDF (DF_sdfs)"
else:
is_unit_level = False
data_type = "Unknown - will attempt to detect firing rate column"
print(f"Detected DataFrame type: {data_type}")
# Reset index if ID is in index
if 'ID' not in df_work.columns and 'ID' in df_work.index.names:
df_work = df_work.reset_index()
if didMap:
df_work['orID'] = df_work['ID']
df_work['ID'] = df_work['orID'].map(didMap)
# Handle brain region column naming variations
if 'BrainRegion' not in df_work.columns:
if 'Bregion' in df_work.columns:
df_work['BrainRegion'] = df_work['Bregion']
elif 'BrainRegion_Mapped' in df_work.columns:
df_work['BrainRegion'] = df_work['BrainRegion_Mapped']
else:
raise ValueError("Cannot find brain region column (BrainRegion, Bregion, or BrainRegion_Mapped)")
# Get unique units - different approach based on data type
if is_unit_level:
# For unit-level data, each row is already a unique unit
unique_units = df_work.copy()
print(f"Unit-level data: {len(unique_units)} units")
else:
# For trial-level data, get unique combinations
unique_units = df_work.drop_duplicates(subset=['UniqueLabel'])
print(f"Trial-level data: {len(unique_units)} unique units from {len(df_work)} trials")
# Extract session from last character of UniqueLabel
unique_units['SessionStr'] = unique_units['UniqueLabel'].str[-1]
# Count units by brain region and unit type
unit_counts = unique_units.groupby(['BrainRegion', 'SU']).size().unstack(fill_value=0)
# Rename columns for clarity (False = MU, True = SU)
unit_counts.columns = ['MU', 'SU']
# Add total column
unit_counts['Total'] = unit_counts['MU'] + unit_counts['SU']
# Sort by brain region alphabetically for consistent ordering across plots
unit_counts = unit_counts.sort_index()
brain_region_order = unit_counts.index.tolist()
# Print the summary
print("Summary of Units by Brain Region")
print("===============================")
print(unit_counts)
print(f"\nOverall Total: {unit_counts['Total'].sum()} units")
print(f"Single Units: {unit_counts['SU'].sum()} ({unit_counts['SU'].sum()/unit_counts['Total'].sum()*100:.1f}%)")
print(f"Multi Units: {unit_counts['MU'].sum()} ({unit_counts['MU'].sum()/unit_counts['Total'].sum()*100:.1f}%)")
# Participant distribution analysis
print("\nParticipant Distribution Analysis")
print("================================")
participant_summary = unique_units.groupby('ID').agg({
'UniqueLabel': 'count',
'SU': ['sum', lambda x: (x == False).sum()] # Count SU and MU
}).round(2)
participant_summary.columns = ['Total_Units', 'Single_Units', 'Multi_Units']
participant_summary['SU_Percentage'] = (participant_summary['Single_Units'] /
participant_summary['Total_Units'] * 100).round(1)
print(participant_summary)
# Session distribution analysis
print("\nSession Distribution Analysis")
print("============================")
session_summary = unique_units.groupby('SessionStr').agg({
'UniqueLabel': 'count',
'SU': ['sum', lambda x: (x == False).sum()],
'ID': 'nunique' # Number of unique participants per session
}).round(2)
session_summary.columns = ['Total_Units', 'Single_Units', 'Multi_Units', 'Participants']
session_summary['SU_Percentage'] = (session_summary['Single_Units'] /
session_summary['Total_Units'] * 100).round(1)
print(session_summary)
# Multi-session participant analysis
participant_sessions = unique_units.groupby('ID')['SessionStr'].apply(list).apply(lambda x: sorted(list(set(x))))
multi_session_participants = participant_sessions[participant_sessions.apply(len) > 1]
print(f"\nMulti-session participants:")
for pid, sessions in multi_session_participants.items():
print(f" {pid}: Sessions {', '.join(sessions)}")
print(f"\nSession participation summary:")
print(f" Single session only: {len(participant_sessions[participant_sessions.apply(len) == 1])} participants")
print(f" Multiple sessions: {len(multi_session_participants)} participants")
# For a more detailed breakdown including each participant's contribution
print("\nDetailed Unit Count by Region and Participant")
print("=============================================")
participant_counts = unique_units.groupby(['BrainRegion', 'ID', 'SU']).size().unstack(fill_value=0)
print(participant_counts)
if participant_counts.shape[1] == 2:
participant_counts.columns = ['MU', 'SU']
elif participant_counts.shape[1] == 1:
# Handle case where all units are same type
if False in participant_counts.columns:
participant_counts.columns = ['MU']
participant_counts['SU'] = 0
else:
participant_counts.columns = ['SU']
participant_counts['MU'] = 0
participant_counts['Total'] = participant_counts['MU'] + participant_counts['SU']
print(participant_counts)
# Calculate mean firing rates for each unit
# Handle different DataFrame types and firing rate columns
if is_unit_level and 'meanFR' in df_work.columns:
# For DF_Units - already has meanFR, just use it directly
mean_frs = unique_units[['UniqueLabel', 'meanFR']].copy()
mean_frs.columns = ['UniqueLabel', 'MeanFR']
print("Using existing meanFR from unit-level data")
elif 'SpikeDensity' in df_work.columns:
# For DF_sdfs - calculate from trial data
mean_frs = df_work.groupby('UniqueLabel')['SpikeDensity'].mean().reset_index()
mean_frs.columns = ['UniqueLabel', 'MeanFR']
print("Calculating mean firing rate from SpikeDensity (trial-level SDF data)")
elif 'meanFR' in df_work.columns:
# For DF_FRall - use existing meanFR column from trial data
mean_frs = df_work.groupby('UniqueLabel')['meanFR'].mean().reset_index()
mean_frs.columns = ['UniqueLabel', 'MeanFR']
print("Using meanFR from trial-level FR data")
else:
# Fallback - look for any FR-related column
fr_candidates = [col for col in df_work.columns if 'fr' in col.lower() or 'rate' in col.lower()]
if fr_candidates:
print(f"Warning: Using column '{fr_candidates[0]}' as firing rate")
if is_unit_level:
mean_frs = unique_units[['UniqueLabel', fr_candidates[0]]].copy()
else:
mean_frs = df_work.groupby('UniqueLabel')[fr_candidates[0]].mean().reset_index()
mean_frs.columns = ['UniqueLabel', 'MeanFR']
else:
raise ValueError("Cannot find firing rate column (meanFR or SpikeDensity)")
# Merge with unit info
unit_fr_data = unique_units.merge(mean_frs, on='UniqueLabel')
# Print SU/MU counts per region to console
print("\nUnit counts by region (SU / MU):")
for region, row in unit_counts.iterrows():
print(f" {region}: {row['SU']} single-units, {row['MU']} multi-units (total {row['Total']})")
# Create the plots - 2 panels
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=figsize)
# Font sizes (publication standard)
_fs_label = 8 # axis labels
_fs_tick = 7 # tick labels
_fs_title = 8 # subplot titles
# Plot 1: Mean firing rates by brain region and unit type
sns.swarmplot(x='BrainRegion', y='MeanFR', data=unit_fr_data, ax=ax1,
hue='SU', dodge=True, order=brain_region_order,size = 3)
ax1.axhline(y=1, color='red', linestyle='--', alpha=0.7, linewidth=1, label='1 Hz threshold')
ax1.set_ylabel('Mean Firing Rate (Hz)', fontsize=_fs_label)
ax1.set_xlabel('Brain Region', fontsize=_fs_label)
ax1.set_title('Firing Rates by Region and Unit Type', fontsize=_fs_title)
ax1.tick_params(labelsize=_fs_tick)
ax1.grid(True, alpha=0.3)
# Fix legend labels: True→single-units, False→multi-units
_handles, _labels = ax1.get_legend_handles_labels()
_label_map = {'True': 'single-units', 'False': 'multi-units',
True: 'single-units', False: 'multi-units'}
_labels = [_label_map.get(l, l) for l in _labels]
ax1.legend(_handles, _labels, fontsize=_fs_tick, title_fontsize=_fs_tick)
# Plot 2: Participant-Session contribution analysis
# Create a heatmap showing units per participant-session per region
# Each row represents a unique ID-Session combination
# Create Session-ID combination identifier
unique_units['Session_ID'] = 'S' + unique_units['SessionStr'] + '_' + unique_units['ID']
# Create matrix with Session-ID combinations as rows, brain regions as columns
session_participant_matrix = unique_units.groupby(['Session_ID', 'BrainRegion']).size().unstack(fill_value=0)
# Sort rows to group by participant (sessions together)
# Extract ID for sorting
session_participant_matrix['sort_id'] = session_participant_matrix.index.str[3:] # Remove 'S1_', 'S2_', etc.
session_participant_matrix['sort_session'] = session_participant_matrix.index.str[1:2] # Extract session number
session_participant_matrix = session_participant_matrix.sort_values(['sort_id', 'sort_session'])
# Remove sorting columns for the heatmap
matrix_for_plot = session_participant_matrix.drop(['sort_id', 'sort_session'], axis=1)
# Create row colors based on session
def get_session_color(session_id):
session = session_id[1] # Extract session number (S1_, S2_, S3_)
session_colors = {'1': 'lightblue', '2': 'lightgreen', '3': 'lightcoral'}
return session_colors.get(session, 'gray')
row_colors = [get_session_color(idx) for idx in matrix_for_plot.index]
# Create the heatmap
im = ax2.imshow(matrix_for_plot.values, aspect='auto', cmap='Blues')
# Add session color bars on the left
for i, color in enumerate(row_colors):
ax2.add_patch(plt.Rectangle((-0.7, i-0.4), 0.3, 0.8, facecolor=color, edgecolor='black', linewidth=0.5))
# Customize the heatmap
ax2.set_xticks(range(len(matrix_for_plot.columns)))
ax2.set_xticklabels(matrix_for_plot.columns, fontsize=_fs_tick)
ax2.set_yticks(range(len(matrix_for_plot.index)))
# Create cleaner y-tick labels (remove 'S1_', 'S2_', etc. prefix for display)
clean_labels = [idx[3:] + f" (S{idx[1]})" for idx in matrix_for_plot.index]
ax2.set_yticklabels(clean_labels, fontsize=_fs_tick)
ax2.set_xlabel('Brain Region', fontsize=_fs_label)
ax2.set_ylabel('Participant (Session)', fontsize=_fs_label)
ax2.set_title('Units per Participant-Session per Region', fontsize=_fs_title)
ax2.tick_params(labelsize=_fs_tick)
# Add text annotations for non-zero values
for i in range(len(matrix_for_plot.index)):
for j in range(len(matrix_for_plot.columns)):
value = matrix_for_plot.iloc[i, j]
if value > 0:
ax2.text(j, i, str(value), ha='center', va='center',
color='white' if value > matrix_for_plot.values.max()/2 else 'black',
fontweight='bold', fontsize=_fs_tick)
# Add colorbar
cbar = plt.colorbar(im, ax=ax2, shrink=0.8)
cbar.set_label('Number of Units', fontsize=_fs_tick)
cbar.ax.tick_params(labelsize=_fs_tick)
# Add session legend with proper participation patterns
from matplotlib.patches import Patch
legend_elements = [
Patch(facecolor='lightblue', label='Session 1'),
Patch(facecolor='lightgreen', label='Session 2'),
Patch(facecolor='lightcoral', label='Session 3')
]
ax2.legend(handles=legend_elements, loc='upper left', bbox_to_anchor=(1.15, 1),
title='Session', fontsize=_fs_tick, title_fontsize=_fs_tick)
plt.tight_layout()
# Print firing rate statistics
print(f"\nFiring Rate Statistics")
print("=====================")
fr_stats = unit_fr_data.groupby(['BrainRegion', 'SU'])['MeanFR'].agg([
'count', 'mean', 'std', 'min', 'max', 'median'
]).round(2)
print(fr_stats)
# Print participant statistics
print(f"\nParticipant Statistics")
print("====================")
print(f"Total participants: {unique_units['ID'].nunique()}")
print(f"Units per participant (mean ± std): {participant_summary['Total_Units'].mean():.1f} ± {participant_summary['Total_Units'].std():.1f}")
print(f"Range: {participant_summary['Total_Units'].min()}-{participant_summary['Total_Units'].max()} units")
# Brain region contribution by participant
region_by_participant = unique_units.groupby(['ID', 'BrainRegion']).size().unstack(fill_value=0)
print(f"\nBrain region sampling across participants:")
for region in region_by_participant.columns:
n_participants = (region_by_participant[region] > 0).sum()
print(f" {region}: {n_participants}/{len(region_by_participant)} participants ({n_participants/len(region_by_participant)*100:.1f}%)")
# Units below threshold analysis
threshold = 1.0 # Hz
below_threshold = unit_fr_data[unit_fr_data['MeanFR'] < threshold]
print(f"\nUnits below {threshold} Hz threshold:")
print(f"Total: {len(below_threshold)}/{len(unit_fr_data)} ({len(below_threshold)/len(unit_fr_data)*100:.1f}%)")
if len(below_threshold) > 0:
threshold_breakdown = below_threshold.groupby(['BrainRegion', 'SU']).size()
print("Breakdown by region and type:")
print(threshold_breakdown)
# Save figure if requested
if save_fig and output_dir:
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
fig_path = output_path / 'unit_overview.png'
plt.savefig(fig_path, dpi=300, bbox_inches='tight')
print(f"\nFigure saved to: {fig_path}")
plt.savefig(os.path.join(output_path, 'unit_overview.svg'),
format='svg', bbox_inches='tight')
# Return summary data
results = {
'unit_counts': unit_counts,
'participant_counts': participant_counts,
'participant_summary': participant_summary,
'session_summary': session_summary,
'firing_rate_stats': fr_stats,
'unit_fr_data': unit_fr_data,
'below_threshold': below_threshold,
'session_participant_matrix': matrix_for_plot, # New session-participant matrix
'participant_region_matrix': unique_units.groupby(['ID', 'BrainRegion']).size().unstack(fill_value=0), # Keep original for compatibility
'figure': fig,
'data_type': data_type # Include detected data type
}
return results
def add_deidentified_unit_id(df_units):
"""Add dUnitID column: P01_M1, P01_S1, etc."""
df = df_units.copy()
df['SU'] = df['SU'].astype(bool)
df['UnitType'] = df['SU'].map({True: 'S', False: 'M'})
df = df.sort_values(['ID', 'UnitType', 'UniqueLabel']).reset_index(drop=True)
df['UnitNum'] = df.groupby(['ID', 'UnitType']).cumcount() + 1
df['dUnitID'] = df['ID'] + '_' + df['UnitType'] + df['UnitNum'].astype(str)
df = df.drop(columns=['UnitNum'])
return df
#%% Main execution - define parameters, load and preprocess data
# =============================================================================
# CONFIGURATION
# =============================================================================
MMNsessions = ['1', '2', '3']
# De-identified participant IDs per session
allIDs = {
'Session1': ['P01', 'P02', 'P03', 'P04', 'P05', 'P06', 'P07', 'P08',
'P09', 'P10', 'P11', 'P12', 'P13'],
'Session2': ['P02', 'P08'],
'Session3': ['P06'],
}
# Analysis parameters
sdf_norm = 'RA' # 'block' or 'RA' - both divide by standard trials
normPeriod = (-0.1, 0.45) # Normalization period (seconds)
rDec = 10 # Decimation factor for time dimension
minFRcrit = 1 # Minimum firing rate (Hz) for unit inclusion
outlier_threshold = 4 # IQR multiplier for outlier trial removal
verbose = True
# Data path - set this to the location of the de-identified data
# The folder should contain Session1/, Session2/, Session3/ subdirectories
dataPath = Path('../Optimum1_ProcessedData')
# Configure normalization settings
if sdf_norm == 'block':
sdf_pos2 = '_Norm'
elif sdf_norm == 'RA':
sdf_pos2 = '_NormRA'
else:
sdf_pos2 = ''
# Define brain regions dictionary (mapping raw electrode labels to regions)
target_regions = {
'HPC': ['HCzD', 'HCzI', 'HcaI', 'HCa', 'hkaI', 'hkaD', 'HkD', 'HkI',
'HipI', 'HCuI', 'HCpD', 'HCu', 'HcuD', 'HCpI', 'hquD', 'HIPI','HD'],
'Amy': ['AMD', 'Am', 'AmD', 'AMII', 'AMID', 'AI', 'AmI'],
'Ins': ['InAnt'],
'TTG': ['TTG', 'chan']
}
# =============================================================================
# DATA LOADING
# =============================================================================
# Storage for SDF data from each subject
sdf_parsing = []
DF_Units = pd.DataFrame()
DF_FRall = pd.DataFrame()
resultsFolder = Path(f'Figures/Session{"".join(MMNsessions)}')
for selMMNSession in MMNsessions:
# Process each subject
for subj in allIDs[f'Session{selMMNSession}']:
if verbose:
print(f"Processing SDF data for {subj}")
# Define filenames (de-identified data structure)
sdfFN = dataPath / f'Session{selMMNSession}' / 'SDFs' / f'{subj}_analysis.mat'
FRtab_FN = dataPath / f'Session{selMMNSession}' / 'FRTabs' / f'{subj}_FRtable'
try:
sdfMAT = read_mat(str(sdfFN))
spkMAT = loadmat(f'{FRtab_FN}.mat', struct_as_record=False, squeeze_me=True)
DF_FR = pd.read_csv(f'{FRtab_FN}.csv')
spkMAT['nsEventsALL'] = sdfMAT['nsEventsALL'].copy()
except Exception as e:
if verbose:
print(f" Error loading data for {subj}: {e}")
# Extract unit information
unitLabels = spkMAT['ALLspikes'].label
unitBreg = spkMAT['ALLspikesInfo'].Bregion
unitisSUAs = spkMAT['ALLspikesInfo'].isSUA
meanFRs = spkMAT['ALLspikesInfo'].meanFR
# Handle single unit case
if isinstance(unitLabels, str):
unitLabels = [unitLabels]
unitBreg = np.array([unitBreg])
unitisSUAs = [unitisSUAs]
meanFRs = np.array([meanFRs])
# --- Probe label corrections ---
if subj == 'P01':
unitBreg[(spkMAT['ALLspikesInfo'].MW == 1)] = 'AmI'#Left HPC probe is actually Amy
if subj == 'P02':
unitBreg[(spkMAT['ALLspikesInfo'].MW == 2)] = 'HD' #PIns probe targeted HPC
if subj == 'P12':
unitBreg[(spkMAT['ALLspikesInfo'].MW == 2)] = 'AmI'# HPC probe targeted Amy
unitBregionOrig = unitBreg.copy()
# Map brain regions for units
for targReg in target_regions:
unitBreg[np.isin(unitBreg, target_regions[targReg])] = targReg
# CREATE UNIT-LEVEL DATAFRAME
# Build unit-level DataFrame for this subject
n_units = len(unitLabels)
unit_data = {
'ID': [subj] * n_units,
'SessionStr': [selMMNSession] * n_units,
'UnitLabel': unitLabels,
'UniqueLabel': [f"{subj}_{label}S{selMMNSession}" for label in unitLabels],
'BregionOrig': unitBregionOrig,
'BrainRegion': unitBreg,
'SU': unitisSUAs,
'meanFR': meanFRs
}
# Add any additional fields from ALLspikesInfo if they exist
additional_fields = ['SNR', 'amplitude', 'templateWidth', 'waveformDuration', 'firingRateStability']
for field in additional_fields:
if hasattr(spkMAT['ALLspikesInfo'], field):
field_data = getattr(spkMAT['ALLspikesInfo'], field)
if isinstance(field_data, (int, float, str)):
field_data = [field_data] * n_units
elif len(field_data) != n_units:
continue # Skip if length doesn't match
unit_data[field] = field_data
# Convert to DataFrame and append
DF_subject_units = pd.DataFrame(unit_data)
DF_Units = pd.concat([DF_Units, DF_subject_units], ignore_index=True)
# Accumulate trial-level FR data (all units, including low-FR) for supplementary analysis
label_to_unique = {label: f"{subj}_{label}S{selMMNSession}" for label in unitLabels}
DF_FR_subj = DF_FR.copy()
DF_FR_subj['UniqueLabel'] = DF_FR_subj['UnitLabel'].map(label_to_unique)
DF_FR_subj['ID'] = subj
DF_FR_subj['SessionStr'] = selMMNSession
DF_FRall = pd.concat([DF_FRall, DF_FR_subj], ignore_index=True)
# Get trial info for this subject
trlInfoTemp = DF_FR.loc[DF_FR.UnitLabel == unitLabels[0]].copy()
# Check data consistency
if trlInfoTemp.shape[0] != sdfMAT['sdf']['trial'].shape[0]:
print(f'{subj}: Inconsistent trial numbers between FRtab and trl')
continue
if sdfMAT['sdf']['label'] != spkMAT['ALLspikes'].label.tolist():
print(f'{subj}: SDF and ALLspikes labels do not match')
continue
# Skip if all units are below minimum firing rate
if np.all(meanFRs <= minFRcrit):
print(f'{subj}: all {len(meanFRs)} units below minimum firing rate ({minFRcrit}) Hz')
continue
# Build SDF DataFrame using our custom function
try:
DF_subj = buildSdfDF_mmn(sdfMAT, spkMAT['ALLspikesInfo'], subj, trlInfoTemp,
target_regions = target_regions,r=rDec, minFR=minFRcrit, normBy=sdf_norm,normPeriod = normPeriod, sessionStr = selMMNSession)
# Drop NaN values in SpikeDensity
DF_subj = DF_subj.dropna(subset=['SpikeDensity'])
# If no valid units, skip to next subject
if DF_subj.empty:
if verbose:
print(f" No valid units found for {subj}")
# Remove outliers if threshold is provided
if outlier_threshold:
# Function to remove outlier trials based on SDF values
# After outlier removal
DF_subj2, outlier_info = remove_sdf_outlier_trials(
DF_subj,
epoch=(-0.45, 0.45),
iqr_multiplier=outlier_threshold,
sdfStr=f'SpikeDensity{sdf_pos2}',
verbose = verbose
)
# save the outlier information
# Create directory for outlier info
outlier_dir = resultsFolder / 'OutlierInfo' / f'Session{selMMNSession}'
Path(outlier_dir).mkdir(parents=True, exist_ok=True)
# Save outlier information for this subject
outlier_file = outlier_dir / f'{subj}_outliers_{sdf_pos2}_{outlier_threshold}.pkl'
# Create a simpler version of outlier_info that only contains what we need for raster plots
raster_outlier_info = {
'unit_labels': outlier_info['unit_labels'],
'outlier_trials': outlier_info['outlier_trials'],
'subject': subj,
'threshold': outlier_threshold,
'epoch': (-0.45, 0.45),
'sdf_type': sdf_pos2
}
# Save outlier info to file
with open(outlier_file, 'wb') as f:
pickle.dump(raster_outlier_info, f)
# Add to list of SDF dataframes
sdf_parsing.append(DF_subj2)
if verbose:
print(f" Added {len(DF_subj)} rows from {subj}")
except Exception as e:
print(f"Error processing SDF data for {subj}: {e}")
# Data is already de-identified - IDs in files are P01, P02, etc.
# No runtime de-identification needed
# Combine all SDFs
if sdf_parsing:
DF_sdfs = pd.concat(sdf_parsing)
if verbose:
print(f"Combined SDF data contains {len(DF_sdfs)} rows")
del(sdf_parsing)
del(sdfMAT)
else:
if verbose:
print("No SDF data was successfully processed")
DF_sdfs = DF_sdfs.reset_index(['BrainRegion','SU'])
DF_sdfs = DF_sdfs[~DF_sdfs.index.get_level_values('habituation').astype(bool)]
#remove low-firing units again
# check for minimum firing rate after removing outlier trials
mFRs = DF_sdfs.groupby('UniqueLabel')['SpikeDensity'].mean()
print(mFRs[mFRs<=minFRcrit].index)
if len(mFRs[mFRs<=minFRcrit])>0:
DF_sdfs = DF_sdfs.loc[~DF_sdfs['UniqueLabel'].isin(mFRs[mFRs<=minFRcrit].index.values)]
#last removal of bad units
# These units were excluded based on visual inspection of raster plots
# (inconsistent spiking, cross-talk, sudden FR changes)
rmUnitLabels = [
'P03_NSX84-1S1', # P03
'P03_NSX84-2S1', # P03
'P04_NSX111-1S1', # P04
'P04_NSX124-2S1', # P04 - questionable
'P06_NSX80-1S1', # P06 - inconsistent spiking
'P07_NSX80-1S1', # P07 - FR increase on last block
'P08_NSX123-1S1', # P08
'P08_NSX124-2S1', # P08
'P08_NSX127-1S1', # P08
'P11_NSX101-1S1', # P11
'P12_NSX73-1S1', # P12 - cross-talk
'P12_NSX104-1S1', # P12 - cross-talk for intensity-up
'P12_NSX111-2S1', # P12 - inconsistent spiking
'P08_NSX66-1S2', # P08 S2 - sudden FR increase
'P08_NSX87-2S2', # P08 S2 - sudden FR increase
'P08_NSX87-4S2', # P08 S2 - sudden FR increase
'P08_NSX125-1S2', # P08 S2 - sudden FR increase
'P06_NSX80-1S3', # P06 S3 - inconsistent spiking
'P06_NSX103-1S3', # P06 S3 - inconsistent spiking
'P06_NSX104-1S3', # P06 S3 - inconsistent spiking
]
DF_sdfs = DF_sdfs.loc[~DF_sdfs['UniqueLabel'].isin(rmUnitLabels)]
#optimizing DF_sdfs
DF_sdfs['eventID'] = DF_sdfs['eventID'].astype('category')
for col in ['trlNum']: