LFP_functions.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
  1. import pandas as pd
  2. import numpy as np
  3. from scipy import signal
  4. from multitaper import mtspec
  5. from scipy import interpolate
  6. import elephant
  7. import neo.core
  8. import quantities as pq
  9. '''
  10. File: LFP_functions.py
  11. Author: Pau Boncompte Carré
  12. Date: 3/02/2024
  13. This file contains the functions used to align the LFP to the presentation of each
  14. image in the stimulus table, as well as other functions used in the analysis of the LFP/CSD.
  15. '''
  16. ##############################################################################################################
  17. #Functions used to align the LFP
  18. ##############################################################################################################
  19. def align_lfp(lfp, trial_window, alignment_times, trial_ids=None, time_windows=None):
  20. '''
  21. Aligns the LFP data array to experiment times of interest
  22. INPUTS:
  23. lfp: data array containing LFP data for one probe insertion
  24. trial_window: vector specifying the time points to excise around each alignment time
  25. alignment_times: experiment times around which to excise data
  26. trial_ids: indices in the session stim table specifying which stimuli to use for alignment.
  27. None if aligning to non-stimulus times
  28. time_windows: dictionary where keys are presentation_ids and values are time windows within which to add values in the aligned_lfp
  29. OUTPUT:
  30. aligned data array with dimensions channels x trials x time
  31. '''
  32. # create a time vector for each trial
  33. time_selection = np.concatenate([trial_window + t for t in alignment_times])
  34. if trial_ids is None:
  35. trial_ids = np.arange(len(alignment_times))
  36. # create a multi-index for the trials and time points
  37. inds = pd.MultiIndex.from_product((trial_ids, trial_window),
  38. names=('presentation_id', 'time_from_presentation_onset'))
  39. # select the data and stack the time points into a new dimension
  40. ds = lfp.sel(time = time_selection, method='nearest').to_dataset(name = 'aligned_lfp')
  41. ds = ds.assign(time=inds).unstack('time')
  42. aligned_lfp = ds['aligned_lfp']
  43. #if time_windows is not None, we mask the values outside the time windows
  44. if time_windows is not None:
  45. for presentation_id, time_window in time_windows.items():
  46. mask = (aligned_lfp.time_from_presentation_onset < time_window[0]) | (aligned_lfp.time_from_presentation_onset > time_window[1])
  47. aligned_lfp.loc[dict(presentation_id=presentation_id)] = aligned_lfp.loc[dict(presentation_id=presentation_id)].where(~mask)
  48. return aligned_lfp
  49. def align_image_lfps(stim_active, lfp, is_norm = False):
  50. '''
  51. This is the main function that aligns the lfp to the presentation of each image in the stimulus table.
  52. Given the stimulus table, it finds the presentation times of each image and then calls the align_lfp function.
  53. INPUTS:
  54. stim_active: stimulus table
  55. lfp: data array containing LFP data for one probe insertion
  56. OUTPUT:
  57. aligned_lfps: dictionary with keys being the image names and values being the aligned lfp xarrays
  58. '''
  59. #get the presentation times of each image and the image names
  60. presentation_times = stim_active.start_time.values
  61. image_names = stim_active['image_name'].unique()
  62. image_names = image_names[image_names != 'omitted']
  63. aligned_lfps = {}
  64. #for every image we obtain an xarray with the aligned lfp.
  65. # We save all of them in a dictionary (keys are the image names and values are the xarrays)
  66. for image_name in image_names:
  67. #we get the info needed for the align_lfp function
  68. im_ids, change_ids, n_ims = get_stim_indexes(stim_active,image_name)
  69. change_times = presentation_times[change_ids]
  70. #find the trial_window (max time window)
  71. trial_window = np.arange(-0.25, 0.75*max(n_ims)-0.25, 1/500)
  72. #create a dictionary with keys being the change ids and values being the time window around each presentation
  73. time_window_dict = {}
  74. for i, change_id in enumerate(change_ids):
  75. time_window_dict[change_id] = [-0.25, 0.75*n_ims[i]-0.25]
  76. #we call the align_lfp function and save the xarray in the dictionary
  77. aligned_lfp = align_lfp(lfp, trial_window, change_times, change_ids, time_window_dict)
  78. #if is_norm is True, we perform energy normalization
  79. if is_norm == True:
  80. aligned_lfp = energy_normalization(aligned_lfp,is_mean=False)
  81. aligned_lfps[image_name] = aligned_lfp
  82. return aligned_lfps
  83. def get_stim_indexes(stim_active,im_name):
  84. '''
  85. This function returns the indexes of the stimulus table where the image appears and the indexes where the image appears after a change.
  86. INPUTS:
  87. stim_active: stimulus table
  88. im_name: name of the image we want to align to
  89. OUTPUT:
  90. im_indexes: list with indexes of the stimulus table where the image appears
  91. change_indexes: list with indexes of the stimulus table where the image appears after a change
  92. n_ims: list wth number of times the image appears after a change
  93. '''
  94. im_indexes = []
  95. change_indexes = []
  96. n_ims = []
  97. i = 0
  98. #we go through the stimulus table and find the indexes where the
  99. #image appears and the indexes where the image appears after a change
  100. while i < len(stim_active):
  101. if stim_active.iloc[i]['is_change'] == True and stim_active.iloc[i]['image_name'] == im_name:
  102. change_indexes.append(i)
  103. im_indexes.append(i)
  104. n_im = 1
  105. i += 1
  106. #we count how many times the image appears after a change
  107. while i < len(stim_active) and stim_active.iloc[i]['image_name'] == im_name:
  108. im_indexes.append(i)
  109. n_im += 1
  110. i += 1
  111. n_ims.append(n_im)
  112. i+=1
  113. return im_indexes, change_indexes, n_ims
  114. ##############################################################################################################
  115. #Functions for energy normalization
  116. ##############################################################################################################
  117. def energy_normalization(signal, is_mean = True):
  118. '''
  119. This function normalizes the energy of each channel of a signal to the maximum energy of all channels.
  120. INPUTS:
  121. signal: data array containing the signal to be normalized.
  122. OUTPUT:
  123. normalized_signal: data array containing the normalized signal
  124. '''
  125. # we check that the data array is 2D
  126. if is_mean == True and signal.ndim != 2:
  127. signal = signal.mean(dim='presentation_id',skipna=True)
  128. normalized_signal = signal.copy()
  129. energy = np.zeros(len(signal.channel))
  130. if signal.ndim != 2:
  131. for j,pres in enumerate(signal.presentation_id):
  132. for i,channel in enumerate(signal.channel):
  133. energy[i] = np.sum((signal.sel(channel=channel).sel(presentation_id=pres)-np.mean(signal.sel(channel=channel).sel(presentation_id=pres)))**2)
  134. for j,pres in enumerate(signal.presentation_id):
  135. for i,channel in enumerate(signal.channel):
  136. normalized_signal.loc[dict(channel=channel)].loc[dict(presentation_id=pres)] = (signal.sel(channel=channel).sel(presentation_id=pres)-np.mean(signal.sel(channel=channel).sel(presentation_id=pres))) * np.sqrt(np.max(energy)/energy[i])
  137. else:
  138. for i,channel in enumerate(signal.channel):
  139. energy[i] = np.sum((signal.sel(channel=channel)-np.mean(signal.sel(channel=channel)))**2)
  140. for i,channel in enumerate(signal.channel):
  141. normalized_signal.loc[dict(channel=channel)] = (signal.sel(channel=channel)-np.mean(signal.sel(channel=channel))) * np.sqrt(np.max(energy)/energy[i])
  142. return normalized_signal
  143. ##############################################################################################################
  144. #Functions for Visual Area Selection
  145. ##############################################################################################################
  146. def select_area(lfps, chans, probe_id, area, is_norm = True, is_mean = False):
  147. '''
  148. This function selects the channels in a visual area and returns the aligned LFPs in that area.
  149. INPUTS:
  150. lfps: dictionary with keys being the image names and values being the LFP xarrays
  151. chans: dataframe with the channels information
  152. probe_id: probe id
  153. area: string with the name of the area
  154. is_norm: boolean indicating whether to perform energy normalization
  155. OUTPUT:
  156. aligned_lfps_area: dictionary with keys being the image names and values being the aligned lfp xarrays in the area
  157. chans_lfp_area: dataframe with the channels information in the area that are used in the LFP
  158. '''
  159. #get image names and a sample of the lfp to find the channels of interest
  160. image_names = list(lfps.keys())
  161. lfp_sample = lfps[image_names[0]]
  162. # We observe the channels in the probe that are in the area
  163. chans_in_area = chans[(chans['probe_id']==probe_id)&(chans['structure_acronym'].str.contains(area))]
  164. first_channel_id = chans_in_area[chans_in_area['structure_acronym'] == area].index.min()
  165. last_channel_id = chans_in_area[chans_in_area['structure_acronym'] == area].index.max()
  166. aligned_lfps_area = {}
  167. #for every image we obtain an xarray with the aligned lfp.
  168. # We save all of them in a dictionary (keys are the image names and values are the xarrays)
  169. for image_name in image_names:
  170. # Get the LFP data for channels in VISp
  171. lfp = lfps[image_name]
  172. aligned_lfp_area = lfp.sel(channel=slice(first_channel_id,last_channel_id))
  173. #if is_norm is True, we perform energy normalization
  174. if is_norm == True:
  175. aligned_lfp_area = energy_normalization(aligned_lfp_area, is_mean = is_mean)
  176. aligned_lfps_area[image_name] = aligned_lfp_area
  177. #save the channels in the area of interest that are used in the LFP (a fourth of the total channels in the area!)
  178. #IMPORTANT: do not confuse chans_lfp_area with chans_in_area, since the later contains the
  179. #channels in the area whilst the former contains the channels in the area that are used in the LFP.
  180. chans_lfp_area = chans_in_area[chans_in_area.index.isin(aligned_lfp_area.channel.values)]
  181. return aligned_lfps_area, chans_lfp_area
  182. ##############################################################################################################
  183. #Functions for CSD
  184. ##############################################################################################################
  185. def get_csd(lfp, ele_pos, channel_start=None, channel_end=None, fs=500, method_csd = 'KCSD1D'):
  186. '''
  187. This function returns the CSD of a signal.
  188. INPUTS:
  189. lfp: data array containing the signal
  190. ele_pos: array containing the positions of the electrodes
  191. channel_start: first channel to consider
  192. channel_end: last channel to consider
  193. OUTPUT:
  194. CSD: analog signal containing the CSD
  195. '''
  196. if channel_start is None:
  197. channel_start = lfp.channel[0]
  198. if channel_end is None:
  199. channel_end = lfp.channel[-1]
  200. #average across presentations (if not done yet) and select the channels
  201. if lfp.ndim != 2:
  202. lfp = lfp.mean(dim='presentation_id',skipna=True)
  203. lfp = lfp.sel(channel=slice(channel_start,channel_end))
  204. #we convert the data array to a neo analog signal
  205. lfp_neo = neo.core.AnalogSignal(lfp, units='mV', sampling_rate=fs*pq.Hz)
  206. lfp_neo = lfp_neo.T
  207. #we get the CSD using the KCSD method
  208. CSD=elephant.current_source_density.estimate_csd(lfp_neo, coordinates=ele_pos, method=method_csd, process_estimate=True)
  209. return CSD
  210. ##############################################################################################################
  211. #Functions for power spectrum
  212. ##############################################################################################################
  213. def butter_highpass(lowcut, fs, order=4):
  214. '''
  215. This function returns the coefficients of a highpass butterworth filter.
  216. INPUTS:
  217. lowcut: cutoff frequency
  218. fs: sampling frequency
  219. order: order of the filter
  220. OUTPUT:
  221. b: numerator coefficients
  222. a: denominator coefficients
  223. '''
  224. nyq = 0.5 * fs #nyquist frequency
  225. low = lowcut / nyq #cutoff frequency
  226. b, a = signal.butter(order, low, btype='highpass', analog=False)
  227. return b, a
  228. def get_spectrum(mysignal, time_start, time_end, overlap= 0, time_window=None, is_interpolated=True, fs=500):
  229. '''
  230. This function returns the spectrum of a signal in a time window using the multitaper
  231. method. It also interpolates the spectrum to have a higher resolution.
  232. INPUTS:
  233. mysignal: data array containing the signal (2D or 3D)
  234. time_start: start time of the time window
  235. time_end: end time of the time window
  236. time_window: time window to use for the spectrum
  237. is_interpolated: boolean indicating whether to interpolate the spectrum
  238. fs: sampling frequency
  239. OUTPUT:
  240. f: frequency vector
  241. Quad: spectrum of the signal
  242. '''
  243. mylfp = mysignal.sel(time_from_presentation_onset=slice(time_start,time_end))
  244. #average across presentation IDs (if not done yet) and channels
  245. if mylfp.ndim != 2:
  246. mylfp = mylfp.mean(dim='presentation_id',skipna=True)
  247. mylfp = mylfp.mean(dim='channel',skipna=True).values
  248. #apply highpass filter to remove frequencies below 4 Hz
  249. b, a = butter_highpass(5, 500)
  250. lfp = signal.filtfilt(b, a, mylfp)
  251. lfp = mylfp
  252. #get the spectrum using multitaper method. We use a time window equal to the time window of the signal,
  253. #so we only get one spectrum with higher resolution. The parameters have been chosen to be optimal for
  254. #the LFP signals we work with.
  255. if time_window == None:
  256. time_window = time_end-time_start
  257. t,f,Quad,MT=mtspec.spectrogram(lfp, 1/fs, time_window-1/fs, olap=overlap, nw=2, kspec=3, fmin=0, fmax=100, iadapt=0)
  258. f = f[:,0]
  259. #interpolate the spectrum
  260. if is_interpolated==True:
  261. f_inter = np.linspace(np.min(f),np.max(f),len(f)*4)
  262. Quad_inter = np.zeros((np.size(Quad,axis=0)*4,np.size(Quad,axis=1)))
  263. for i in range(np.size(Quad,1)):
  264. func1 = interpolate.interp1d(f,Quad[:,i],kind='cubic')
  265. Quad_inter[:,i] = func1(f_inter)
  266. return f_inter, Quad_inter
  267. else:
  268. return f, Quad
  269. def get_spectrum2(mysignal, time_start, time_end, overlap= 0, time_window=None, is_interpolated=True, fs=500):
  270. '''
  271. This function returns the spectrum of a signal in a time window using the multitaper
  272. method. Moreover, it performs the average after the multitaper method to avoid loss of
  273. information due to the averaging of the signal.
  274. INPUTS:
  275. mysignal: data array containing the signal (channel x presentation_id x time)
  276. time_start: start time of the time window
  277. time_end: end time of the time window
  278. time_window: time window to use for the spectrum
  279. is_interpolated: boolean indicating whether to interpolate the spectrum
  280. fs: sampling frequency
  281. OUTPUT:
  282. f: frequency vector
  283. spectrum: spectrum of the signal
  284. '''
  285. #select the signal in the time window
  286. mylfp = mysignal.sel(time_from_presentation_onset=slice(time_start,time_end))
  287. #get the spectrum using multitaper method. We use a time window equal to the time window of the signal,
  288. #so we only get one spectrum with higher resolution. The parameters have been chosen to be optimal for
  289. #the LFP signals we work with.
  290. if time_window == None:
  291. time_window = time_end-time_start
  292. #We save the spectrum for each presentation in channel_list and then average them in spectrum_list
  293. #to get the final spectrum of all channels. If there is an error in the spectrum calculation
  294. #(mainly due to the presence of nan values), we skip the presentation.
  295. spectrum_list = []
  296. for i in range(np.size(mylfp.channel)):
  297. channel_list = []
  298. for j in range(np.size(mylfp.presentation_id)):
  299. try:
  300. lfp = mylfp.isel(channel=i).isel(presentation_id=j).values
  301. _,f,Quad,_=mtspec.spectrogram(lfp, 1/fs, time_window-1/fs, olap=overlap, nw=2, kspec=3, fmin=0, fmax=100, iadapt=0)
  302. channel_list.append(Quad)
  303. except:
  304. continue
  305. spectrum_list.append(np.mean(channel_list,axis=0))
  306. spectrum = np.mean(spectrum_list,axis=0)
  307. f = f[:,0]
  308. #interpolate the spectrum
  309. if is_interpolated==True:
  310. f_inter = np.linspace(np.min(f),np.max(f),len(f)*4)
  311. spectrum_inter = np.zeros((np.size(spectrum,axis=0)*4,np.size(spectrum,axis=1)))
  312. for i in range(np.size(spectrum,1)):
  313. func1 = interpolate.interp1d(f,spectrum[:,i],kind='cubic')
  314. spectrum_inter[:,i] = func1(f_inter)
  315. return f_inter, spectrum_inter
  316. else:
  317. return f, spectrum
  318. def get_spectrum_chan(mysignal, time_start, time_end, overlap= 0, time_window=None, is_interpolated=True, fs=500):
  319. '''
  320. This function returns the spectrum of a signal in a time window using the multitaper
  321. method. It is intended for single channel signals.
  322. INPUTS:
  323. mysignal: data array containing the signal (presentation_id x time)
  324. time_start: start time of the time window
  325. time_end: end time of the time window
  326. time_window: time window to use for the spectrum
  327. is_interpolated: boolean indicating whether to interpolate the spectrum
  328. fs: sampling frequency
  329. OUTPUT:
  330. f: frequency vector
  331. spectrum: spectrum of the signal
  332. '''
  333. #select the signal in the time window
  334. mylfp = mysignal.sel(time_from_presentation_onset=slice(time_start,time_end))
  335. #get the spectrum using multitaper method. We use a time window equal to the time window of the signal,
  336. #so we only get one spectrum with higher resolution. The parameters have been chosen to be optimal for
  337. #the LFP signals we work with.
  338. if time_window == None:
  339. time_window = time_end-time_start
  340. #We save the spectrum for each presentation in channel_list and then average them in spectrum_list
  341. #to get the final spectrum of all channels. If there is an error in the spectrum calculation
  342. #(mainly due to the presence of nan values), we skip the presentation.
  343. channel_list = []
  344. for j in range(np.size(mylfp.presentation_id)):
  345. try:
  346. lfp = mylfp.isel(presentation_id=j).values
  347. _,f,Quad,_=mtspec.spectrogram(lfp, 1/fs, time_window-1/fs, olap=overlap, nw=2, kspec=3, fmin=0, fmax=100, iadapt=0)
  348. channel_list.append(Quad)
  349. except:
  350. continue
  351. spectrum = np.mean(channel_list,axis=0)
  352. f = f[:,0]
  353. #interpolate the spectrum
  354. if is_interpolated==True:
  355. f_inter = np.linspace(np.min(f),np.max(f),len(f)*4)
  356. spectrum_inter = np.zeros((np.size(spectrum,axis=0)*4,np.size(spectrum,axis=1)))
  357. for i in range(np.size(spectrum,1)):
  358. func1 = interpolate.interp1d(f,spectrum[:,i],kind='cubic')
  359. spectrum_inter[:,i] = func1(f_inter)
  360. return f_inter, spectrum_inter
  361. else:
  362. return f, spectrum
  363. def get_spectrogram(mysignal, time_start, time_end, overlap= 0, time_window=None, is_interpolated=True ,channel_start=None, channel_end=None, fs=500):
  364. '''
  365. This function returns the spectrogram of a signal in a time window using the multitaper
  366. INPUTS:
  367. mysignal: 3D data array containing the signal (channel x presentation_id x time)
  368. time_start: start time of the time window
  369. time_end: end time of the time window
  370. time_window: time window to use for the spectrum
  371. is_interpolated: boolean indicating whether to interpolate the spectrum
  372. channel_start: first channel to consider
  373. channel_end: last channel to consider
  374. fs: sampling frequency
  375. OUTPUT:
  376. frespec: frequency vector
  377. lfpspecs: spectrogram of the signal, a 3D array with dimensions (frequency x time x presentation_id)
  378. '''
  379. if time_window == None:
  380. time_window = time_end-time_start-(1/fs)
  381. #for the first presentation, we get the spectrogram and save the frequency vector
  382. for i0 in range(np.size(mysignal.presentation_id)):
  383. try:
  384. lfpspec = mysignal.sel(time_from_presentation_onset=slice(time_start,time_end)).isel(presentation_id=i0)
  385. frespec,sigspec = get_spectrum(lfpspec,time_start,time_end,time_window=time_window,overlap=overlap,is_interpolated=False)
  386. break
  387. except ValueError:
  388. continue
  389. #we create the 3D array with the spectrogram of the signal for every presentation_id
  390. lfpspecs = np.zeros((np.size(sigspec,0),np.size(sigspec,1),np.size(mysignal.presentation_id)))
  391. lfpspecs[:,:,0] = sigspec
  392. for i in range(i0,np.size(mysignal.presentation_id)):
  393. lfpspec = mysignal.sel(time_from_presentation_onset=slice(time_start,time_end)).isel(presentation_id=i)
  394. try:
  395. _,sigspec = get_spectrum(lfpspec,time_start,time_end,time_window=time_window,overlap=overlap,is_interpolated=False)
  396. lfpspecs[:,:,i] = sigspec
  397. except ValueError:
  398. lfpspecs[:,:,i] = np.full((np.size(sigspec,0),np.size(sigspec,1)),np.nan)
  399. return frespec,lfpspecs
  400. ##############################################################################################################
  401. #Miscellaneous functions
  402. ##############################################################################################################
  403. def pres_times(n_pres):
  404. '''
  405. Given that the first stimulus is from 0 to 0.25s and between stimuli there is a 0.5 interval (therefore
  406. the second stimulus is 0.75-1s, the thirs is 1.5-1.75s, etc), this function returns the presentation times
  407. (onset and offest) given the number of the stimulus. If n_pres is 0, return the times between -0.25 and 0s.
  408. (before the first stimulus).
  409. INPUTS:
  410. n_pres: number of the stimulus
  411. OUTPUT:
  412. t_0: start time of the stimulus
  413. t_f: end time of the stimulus
  414. '''
  415. if n_pres == 0:
  416. t_0 = -0.25
  417. t_f = 0
  418. else:
  419. t_0 = (n_pres-1)*0.75
  420. t_f = t_0 + 0.25
  421. return t_0, t_f