OSCR

Cheese3D enables sensitive detection and analysis of whole-face movement in mice.

Code ↔ Paper

19 matches between paragraphs of the paper and lines of its authors' code, computed by the harvester (lexical-v1). Click a colored paragraph or line to see its counterpart.

The 19 matches
  1. [1] § Results › Uncovering underlying physiology from external facial movements ↔ paper/fig3-part1-cheese3d-general-anesthesia-eeg.ipynb, lines 553–643 · score 0.88 · EEG spectrogram, vertical scale bar, 0.2–1 Hz, EEG frequency band, FFT window, nose bulge volume
  2. [2] § Methods › Analysis of chewing kinematics ↔ paper/fig4-part1-chewing-whole-face-kinematics.ipynb, lines 681–738 · score 0.86 · find_peaks, median filtering, linearly interpolating, Kneedle, envelope, mouth area
  3. [3] § Methods › Analysis of in vivo electrical stimulation and electrophysiological recording ↔ fig5-part2-cheese3d-synchronized-electrophysiology.ipynb, lines 1110–1204 · score 0.82 · cyclic shuffling, corresponding lag, cross correlated, firing rates, facial movements, facial feature
  4. [4] § Methods › Anatomical-based interpretable feature selection ↔ packages/cheese3d/cheese3d/anatomy.py, lines 309–335 · score 0.81 · convex hull, right pad side, right pad top, nose bottom, axis, volume
  5. [5] § Results › Linking facial movement to motor control machineries using synchronized Cheese3D with electrophysiology ↔ fig5-part2-cheese3d-synchronized-electrophysiology.ipynb, lines 1110–1204 · score 0.81 · confidence interval, peak correlation, shuffled spike, cross correlation, firing rate, facial movements
  6. [6] § Methods › Analysis of in vivo electrical stimulation and electrophysiological recording ↔ fig5-part3-prediction-of-neural-activity-from-cheese3d.ipynb, lines 1113–1255 · score 0.80 · neural activity, cross validated, populationglm, intercept, GLMs, chunk
  7. [7] § Methods › Analysis of kinematics during anesthesia ↔ paper/fig3-part1-cheese3d-general-anesthesia-eeg.ipynb, lines 853–954 · score 0.79 · squared error, cross validation, eye height, nose bulge, PolynomialFeatures, ear angle
  8. [8] § Results › Reduction of tracking noise enables precise measurement of subtle and transient movements across facial regions ↔ paper/fig2-cheese3d-jitter-analysis.ipynb, lines 705–759 · score 0.75 · post triangulation, Jitter comparison, lateralized facial features, midline features, single mouse, camera view
  9. [9] § Results › Uncovering underlying physiology from external facial movements ↔ paper/fig3-part2-prediction-of-eeg-from-facial-features.ipynb, lines 1068–1157 · score 0.65 · sub delta, nose bulge volume, linear model, eye height, ear angle, VAR
  10. [10] § Results › Reduction of tracking noise enables precise measurement of subtle and transient movements across facial regions ↔ supfig5-cheese3d-jitter.ipynb, lines 925–1025 · score 0.63 · Whisker pad bulge, percentile jitter, Eye area, Nose bulge, Ear angle, facial region
  11. [11] § Results › Uncovering underlying physiology from external facial movements ↔ paper/fig3-part1-cheese3d-general-anesthesia-eeg.ipynb, lines 1155–1221 · score 0.61 · sub delta frequency, EEG power, frequency band power, theta, anesthetic, injection
  12. [12] § Results › Uncovering underlying physiology from external facial movements ↔ paper/fig3-part1-cheese3d-general-anesthesia-eeg.ipynb, lines 1223–1287 · score 0.56 · general anesthesia, movement raster, jitter threshold, facial movement, poses, vertical
  13. [13] § Methods › Video capture, synchronization and 3D calibration system ↔ packages/cheese3d/cheese3d/project.py, lines 478–557 · score 0.56 · square side length, board, ChArUco, pipeline, Anipose, triangulation
  14. [14] § Results › Uncovering underlying physiology from external facial movements ↔ paper/fig3-part2-prediction-of-eeg-from-facial-features.ipynb, lines 1377–1447 · score 0.54 · sub delta, EEG power, theta, latent, variance, predict
  15. [15] § Results › Linking facial movement to motor control machineries using synchronized Cheese3D with electrophysiology ↔ fig5-part3-prediction-of-neural-activity-from-cheese3d.ipynb, lines 1662–1774 · score 0.53 · explained variance, neural activity, ear angle, Poisson, predict, mice
  16. [16] § Results › Reduction of tracking noise enables precise measurement of subtle and transient movements across facial regions ↔ packages/cheese3d/cheese3d/interactive.py, lines 1–84 · score 0.53 · pose tracking, keypoint tracking, tool, Cheese3D, triangulation, training
  17. [17] § Methods › Analysis of kinematics during anesthesia ↔ paper/fig3-part2-prediction-of-eeg-from-facial-features.ipynb, lines 943–1027 · score 0.53 · fold cross validation, latent, smooth, predicted, EEG, linear
  18. [18] § Methods › Analysis of kinematics during anesthesia ↔ paper/fig3-part2-prediction-of-eeg-from-facial-features.ipynb, lines 1159–1243 · score 0.52 · latent model, eye height, nose bulge, ear angle, facial features, predict
  19. [19] § Methods › Analysis of chewing kinematics ↔ paper/fig4-part1-chewing-whole-face-kinematics.ipynb, lines 1178–1217 · score 0.52 · peak cross correlation, phases, mastication, ingestion, mouth area, chewing

Paper

Loaded from Europe PMC by your browser, not stored by OSCR: doi.org · Europe PMC

The paper is loaded when this pane is shown.

The authors' code

Jupyter notebook · 1,578 lines · 65 KB · MIT · 4 matches

  1. # %% [markdown]
  2. # # FIGURE 3 (Feb 2025 submission)
  3. # %% [markdown]
  4. # This notebook gathers the code to make all the panels in Figure 3. In order to run this notebook, you need the following anipose projects:
  5. # 1. 20231013-long-anes-rig2
  6. # 2. 202408-eeg-emg-all
  7. # %% [markdown]
  8. # ## Prep the notebook
  9. # %% [markdown]
  10. # ### Load libraries
  11. # %%
  12. %load_ext autoreload
  13. %autoreload 2
  14. # Update path as if notebook was run from top-level repo directory
  15. import os
  16. import sys
  17. pwd = %pwd
  18. if pwd.endswith('notebooks'):
  19. sys.path.insert(0, os.path.abspath('..'))
  20. new_pwd = os.path.abspath(f"{pwd}/..")
  21. %cd {new_pwd}
  22. # %%
  23. import numpy as np
  24. import pandas as pd
  25. import matplotlib.pyplot as plt
  26. import matplotlib.ticker as ticker
  27. import seaborn as sns
  28. import itertools
  29. import pickle
  30. import json
  31. from glob import glob
  32. from numpy import newaxis as na
  33. from datetime import datetime
  34. from pathlib import Path
  35. from functools import reduce
  36. import scipy.stats as stats
  37. from scipy.stats import pearsonr, ttest_rel, wilcoxon
  38. from scipy.signal import convolve, spectrogram, sosfilt, iirdesign
  39. from sklearn.preprocessing import PolynomialFeatures
  40. from sklearn.model_selection import GridSearchCV
  41. from sklearn.linear_model import LinearRegression, Ridge, Lasso, ElasticNet
  42. from matplotlib.colors import ListedColormap
  43. from datetime import datetime
  44. from fepipeline.features.landmarks import read_3d_data
  45. from fepipeline.anatomy import compute_measurements_df
  46. from labutils.utils import maybe
  47. from labutils.plotting import (sns_setup,
  48. landmark_cmap,
  49. measurements_cmap,
  50. save_figure)
  51. # %% [markdown]
  52. # ### Global variables
  53. # %%
  54. sns_setup(palette="colorblind", font="sans-serif")
  55. OUTPUT_DIR = "./figures/Figure3-EEG"
  56. os.makedirs(OUTPUT_DIR, exist_ok=True)
  57. TODAY = datetime.today().date()
  58. # size in frames of moving average filter window for seeing slow drift in behavior
  59. FILTER_WINDOW = 1000
  60. # Start of anesthesia video after injection (in seconds)
  61. ANES_START_OFFSETS = {
  62. ("20240822", "B47"): 4 * 60 + 22,
  63. ("20240822", "B48"): 3 * 60 + 50,
  64. ("20240912", "B47"): 4 * 60 + 17,
  65. ("20240912", "B48"): 4 * 60 + 17,
  66. ("20240913", "B47"): 6 * 60 + 30, #anes video only
  67. ("20240913", "B48"): 3 * 60 + 33, #anes video only
  68. ("20240917", "B47"): 4 * 60 + 40,
  69. ("20240917", "B48"): 4 * 60 + 3,
  70. ("20240918", "B47"): 3 * 60 + 20,
  71. ("20240918", "B48"): 3 * 60 + 45,
  72. ("20240924", "B53"): 3 * 60 + 5, #anes video only
  73. ("20240925", "B53"): 5 * 60 + 56,
  74. ("20240926", "B53"): 4 * 60 + 3, #anes video only
  75. ("20240927", "B53"): 3 * 60 + 0, #anes video only
  76. }
  77. REGION_ORDER = ["eye(left)", "eye(right)",
  78. "ear(left)", "ear(right)",
  79. "nose", "cheek", "mouth"]
  80. DATA_CACHE = "measurements-data-cache-2024"
  81. os.makedirs(DATA_CACHE, exist_ok=True)
  82. MEAS_DATA_CACHE = os.sep.join([DATA_CACHE, "eeg-slow-drift.pkl"])
  83. ALL_DATA_CACHE = os.sep.join([DATA_CACHE, "eeg-slow-drift-eeg-meas.pkl"])
  84. MODEL_RESULTS_CACHE = os.sep.join([DATA_CACHE, "long-anes-slow-drift-results.pkl"])
  85. # Data cache for time-prediction models using Lasso - Alpha optimization
  86. # Using facial features
  87. LASSO_MODEL_FF = os.sep.join([DATA_CACHE, f"{TODAY}-lasso-model-ff.pkl"])
  88. # Using eeg features
  89. LASSO_MODEL_EEG = os.sep.join([DATA_CACHE, f"{TODAY}-lasso-model-eeg.pkl"])
  90. ANIPOSE_BASE = 'anipose-projects/202408-eeg-emg-all'
  91. COORDINATE_PATHS = {}
  92. key_cols = ('date', 'mouse', 'condition')
  93. for p in Path(ANIPOSE_BASE).glob('*/pose-3d/*.csv'):
  94. date, mouse, *_ = p.name.split('_')
  95. if date in ["20240816", "20240822"]:
  96. continue
  97. condition = 'awake' if 'awake' in p.name else 'anes'
  98. COORDINATE_PATHS[(date, mouse, condition)] = p
  99. data_keys = list(COORDINATE_PATHS.keys())
  100. anes_data_keys = [d for d in data_keys if d[2] != 'awake']
  101. # %%
  102. def compute_sample_rate(timestamps):
  103. start_time = datetime.strptime(timestamps[0], "%H:%M:%S:%f")
  104. times = np.array([(datetime.strptime(t, "%H:%M:%S:%f") - start_time).total_seconds()
  105. for t in timestamps])
  106. return 1 / np.mean(np.diff(times))
  107. def read_signal(filename, sample_rate):
  108. df = pd.read_csv(filename, sep="\t", names=["timestamp", "signal"])
  109. _sample_rate = compute_sample_rate(df["timestamp"].values)
  110. if abs(sample_rate - _sample_rate) / sample_rate > 0.1:
  111. return df["signal"].values, _sample_rate
  112. else:
  113. return df["signal"].values, sample_rate
  114. # Remove NaNs at the beginning of the EEG recording and define new recording start
  115. EEG_STARTS = os.sep.join([DATA_CACHE, 'eeg-anes-start.pkl'])
  116. if os.path.exists(EEG_STARTS):
  117. with open(EEG_STARTS, "rb") as fio:
  118. ANES_START_OFFSETS_EEG = pickle.load(fio)
  119. else:
  120. ANES_START_OFFSETS_EEG = {}
  121. for date, mouse, cond in data_keys:
  122. folder = glob(f"ephys-data/{date}_*{mouse}*_EEG-EMG-rec_rig2")
  123. with open(os.sep.join([folder[0], f"{date}_{mouse}_{cond}.align.json"])) as f:
  124. alignment = json.load(f)
  125. lag_time = alignment["lag_time"]
  126. sample_rate = alignment["sample_rate"]
  127. for name, units in (("eeg", "V"), ("emg", "V"), ("temp", "C")):
  128. filename = os.sep.join([folder[0], f"{date}_{mouse}_{cond}_{name}.txt"])
  129. signal, _sample_rate = read_signal(filename, sample_rate)
  130. # if name in ['emg', 'eeg']:
  131. signal[np.isnan(signal)] = 0 #added by inm - assign the nan to 0s then save signal from first non-zero
  132. signal_start = signal.nonzero()[0][0]
  133. signal = signal[signal_start:]
  134. if name == 'eeg' and signal_start and cond == 'anes':
  135. ANES_START_OFFSETS_EEG[date, mouse] = ANES_START_OFFSETS[date, mouse] + int(signal_start/_sample_rate)
  136. # %% [markdown]
  137. # ### Define colormaps
  138. # %%
  139. _, LANDMARK_CMAP = landmark_cmap()
  140. _, MEASUREMENT_CMAP = measurements_cmap()
  141. CONTROL_CMAP = sns.color_palette([sns.color_palette("colorblind")[2],
  142. sns.color_palette("colorblind")[-3],
  143. sns.color_palette("colorblind")[3]])
  144. FREQ_CMAP = "jet"
  145. # %%
  146. EEG_CONTROL_CMAP = sns.color_palette([sns.color_palette("colorblind")[0],
  147. sns.color_palette("colorblind")[7],
  148. sns.color_palette("colorblind")[0],
  149. sns.color_palette("colorblind")[0]])
  150. EEG_CONTROL_CMAP
  151. # %%
  152. CONTROL_CMAP_V2 = sns.color_palette([sns.color_palette("colorblind")[3],
  153. sns.color_palette("colorblind")[-3],
  154. sns.color_palette("colorblind")[2],
  155. sns.color_palette("colorblind")[0],
  156. sns.color_palette("colorblind")[1],
  157. sns.color_palette("colorblind")[-1],
  158. sns.color_palette("colorblind")[4],
  159. sns.color_palette("colorblind")[-2]])
  160. CONTROL_CMAP_V2
  161. # %% [markdown]
  162. # ### Define functions
  163. # %%
  164. def z_score(raw_data):
  165. mean = np.mean(raw_data, 0, keepdims=True)
  166. sigma = np.std(raw_data, 0, keepdims=True) #1
  167. sigma[sigma == 0] = 1
  168. return (raw_data-mean)/sigma
  169. def un_zscore(raw_data, z_score_data): #Undo z-scoring for plotting as a sanity check - there is no deformation
  170. mean = np.mean(raw_data, 0, keepdims=True) #0
  171. sigma = np.std(raw_data, 0, keepdims=True) #1
  172. sigma[sigma == 0] = 1
  173. return z_score_data*sigma + mean
  174. # %%
  175. def moving_avg_filter(signal, window = 3, stride = 1, truncate = True):
  176. kernel = np.ones(window) / window
  177. result = convolve(signal, kernel, mode="same")[window:-window]
  178. return result[::stride]
  179. # %%
  180. test = moving_avg_filter(np.arange(12), window = 1, stride = 2)
  181. print(test)
  182. # %%
  183. def plot_spectrum_helper(dates, mice, conditions, spectrums, lag_times, color,
  184. min_power = None, max_power = None):
  185. single = (len(spectrums.values) == 1)
  186. if single:
  187. if min_power is None:
  188. min_power = np.min(spectrums.values[0][-1])
  189. if max_power is None:
  190. max_power = np.max(spectrums.values[0][-1])
  191. else:
  192. if min_power is None:
  193. min_power = min(*(np.min(s) for _, _, s in spectrums.values))
  194. if max_power is None:
  195. max_power = max(*(np.max(s) for _, _, s in spectrums.values))
  196. awake = (conditions == "awake")
  197. # print(awake)
  198. if sum(awake):
  199. fs, ts, power = spectrums[awake].values[0]
  200. awake_end = ts[-1]
  201. else:
  202. awake_end = 0
  203. fs, ts, power = spectrums[~awake].values[0]
  204. anes_start = ANES_START_OFFSETS[(dates.values[0], mice.values[0])]
  205. ts = ts + lag_times[~awake].values[0] + anes_start #+ awake_end
  206. # print(power)
  207. p = plt.pcolormesh(ts / 60, fs, power,
  208. vmin=min_power, vmax=max_power, cmap=FREQ_CMAP)
  209. # %%
  210. def compute_power_bands(fs, power):
  211. sub_delta_idx = np.where(fs < 1)[0]
  212. sub_delta_power = np.mean(power[sub_delta_idx], axis=0)
  213. delta_idx = np.where((fs >= 1) & (fs <= 4))[0]
  214. delta_power = np.mean(power[delta_idx], axis=0)
  215. theta_idx = np.where((fs >= 5) & (fs <= 10))[0]
  216. theta_power = np.mean(power[theta_idx], axis=0)
  217. # slow_delta_idx = np.where(fs <= 4)[0]
  218. # slow_delta_power = np.mean(power[slow_delta_idx], axis=0)
  219. return sub_delta_power, delta_power, theta_power #, slow_delta_power
  220. # %%
  221. def generate_lagged_data(data, lags = 1, bias = True, initial_bias = True):
  222. assert data.ndim == 2 or data.ndim == 1
  223. # make the data a matrix
  224. if data.ndim == 1:
  225. data = np.expand_dims(data, axis=1)
  226. # make shifted copies of data
  227. lagged = np.lib.stride_tricks.sliding_window_view(data, lags, axis=0)
  228. lagged = np.reshape(lagged, (data.shape[0] - lags + 1, -1))
  229. # pad in the initial lags
  230. if lags > 1:
  231. pad = np.stack([np.pad(data[:(lags - i)], ((i, 0), (0, 0))).T
  232. for i in range(lags - 1, 0, -1)])
  233. pad = np.reshape(pad, (lags - 1, -1))
  234. lagged = np.concatenate([pad, lagged], axis=0)
  235. # always have initial time point
  236. if initial_bias:
  237. lagged = np.concatenate([np.tile(data[0], (lagged.shape[0], 1)), lagged], axis=1)
  238. # add bias term
  239. if bias:
  240. lagged = np.pad(lagged, ((0, 0), (1, 0)), constant_values=1)
  241. return lagged
  242. # Uncomment prints for examples:
  243. x = np.arange(10)
  244. y = np.stack([x, x + 10], axis=1)
  245. # print(generate_lagged_data(x, 5))
  246. # print(generate_lagged_data(y, 3))
  247. # %%
  248. def generate_poly_feats(data, degree = 2, interaction = True):
  249. assert data.ndim == 2 or data.ndim == 1
  250. # make the data a matrix
  251. if data.ndim == 1:
  252. data = np.expand_dims(data, axis=1)
  253. if interaction == True:
  254. return PolynomialFeatures(degree).fit_transform(data)
  255. else:
  256. polyfeats = np.vstack((np.ones(len(data)), data.T)).T
  257. for n in (np.arange(1,degree)+1):
  258. polyfeats = np.hstack((polyfeats, data**n))
  259. return polyfeats
  260. # Uncomment prints for examples:
  261. z = np.stack([x, x + 10, x + 1], axis=1)
  262. print(generate_lagged_data(x, bias=False))
  263. print(generate_poly_feats(generate_lagged_data(z, bias=False)))
  264. print(generate_poly_feats(generate_lagged_data(x, bias=False), 3))
  265. # %%
  266. def get_features(measures, degree = 2, lags = 1, interaction = True, initial_bias = True):
  267. features = generate_lagged_data(measures, lags, bias=False, initial_bias = initial_bias)
  268. features = generate_poly_feats(features, degree, interaction)
  269. return features
  270. # %%
  271. def plot_predictions(ts, xs, color, label, FILTER_WINDOW = 1000, dash_line = True, ax = None):
  272. ts = ts.values[0][FILTER_WINDOW:-FILTER_WINDOW]
  273. xs = moving_avg_filter(xs.values[0], FILTER_WINDOW)
  274. if ax is None:
  275. sns.lineplot(x=ts, y=xs, color=color, label=label)
  276. if dash_line:
  277. sns.lineplot(x=ts, y=ts, color="black", linestyle="dashed")
  278. else:
  279. sns.lineplot(x=ts, y=xs, color=color, label=label, ax = ax)
  280. if dash_line:
  281. sns.lineplot(x=ts, y=ts, color="black", linestyle="dashed", ax = ax)
  282. # %%
  283. def format_feature_ticks(feature):
  284. text = feature.get_text()
  285. if text == "whole-face":
  286. return "whole\nface"
  287. if ' ' in text:
  288. parts = text.split(" ")
  289. return'\n'.join(parts)
  290. else:
  291. parts = text.split(", ")
  292. return ',\n'.join(parts)
  293. # %%
  294. def test_mean_per_run(results, n_runs, feat):
  295. mean_test = []
  296. for r in np.arange(n_runs):
  297. mean_test.append([r, feat, np.mean(results.query("set == 'Test' & run == @r")["RMSE"].values)])
  298. return pd.DataFrame(mean_test, columns = ["run",
  299. "features",
  300. "mean_RMSE"])
  301. # %% [markdown]
  302. # ### Load data
  303. # %%
  304. if os.path.exists(MEAS_DATA_CACHE):
  305. meas_df = pd.read_pickle(MEAS_DATA_CACHE)
  306. else:
  307. coord_data = {k: read_3d_data(v.parent.parent.as_posix())
  308. for k, v in COORDINATE_PATHS.items()}
  309. meas_df = compute_measurements_df(coord_data, key_columns=key_cols)
  310. meas_df = meas_df.assign(sample_rate=100.0, lag_time=0.0)
  311. pd.to_pickle(meas_df, MEAS_DATA_CACHE)
  312. meas_df = meas_df.drop(columns=["measurement_value", "std", "count"])
  313. meas_df
  314. # %%
  315. if os.path.exists(ALL_DATA_CACHE):
  316. data = pd.read_pickle(ALL_DATA_CACHE)
  317. else:
  318. eeg_rows = []
  319. for date, mouse, cond in data_keys:
  320. folder = glob(f"ephys-data/{date}_*{mouse}*_EEG-EMG-rec_rig2")
  321. if len(folder) != 1:
  322. print(f"Found too many (or no) sources={folder} for {date=}, {mouse=}, {cond=}")
  323. continue
  324. with open(os.sep.join([folder[0], f"{date}_{mouse}_{cond}.align.json"])) as f:
  325. alignment = json.load(f)
  326. lag_time = alignment["lag_time"]
  327. sample_rate = alignment["sample_rate"]
  328. for name, units in (("eeg", "V"), ("emg", "V"), ("temp", "C")):
  329. filename = os.sep.join([folder[0], f"{date}_{mouse}_{cond}_{name}.txt"])
  330. signal, _sample_rate = read_signal(filename, sample_rate)
  331. # if name in ['emg', 'eeg']:
  332. signal[np.isnan(signal)] = 0 #added by inm - assign the nan to 0s then save signal from first non-zero
  333. signal_start = signal.nonzero()[0][0]
  334. signal = signal[signal_start:]
  335. if name == 'eeg' and signal_start:
  336. ANES_START_OFFSETS[date, mouse] = ANES_START_OFFSETS[date, mouse] + signal_start/_sample_rate
  337. eeg_rows.append([date, mouse, cond,
  338. "ephys", name, units, "ephys",
  339. signal, _sample_rate, lag_time])
  340. data = pd.concat([meas_df, pd.DataFrame(eeg_rows, columns=meas_df.columns)])
  341. pd.to_pickle(data, ALL_DATA_CACHE)
  342. data
  343. # %%
  344. from copy import deepcopy
  345. def compute_spectrogram(row):
  346. signal = row["timeseries"]
  347. # if np.sum(np.isnan(signal))>0:
  348. # print("signal", np.sum(np.isnan(signal)))
  349. # signal[np.isnan(signal)] = 0 #added by inm - assign the nan to 0s so that they do not propagate when computing spectrogram - to be improved
  350. eeg_filt = iirdesign(50, 55, 1, 50, fs=row["sample_rate"], output="sos")
  351. fs, ts, power = spectrogram(sosfilt(eeg_filt, signal),
  352. fs=row["sample_rate"],
  353. nperseg= 5000,#2000, #originally 2048 ; possibly change to 2050 or 2000 (divisible by 10) - number of samples in each fft segment
  354. # noverlap=512,
  355. scaling="spectrum",
  356. mode="magnitude")
  357. # if np.sum(np.isnan(power)):
  358. # print("power", np.sum(np.isnan(power))) #nans are not here but appear in the plot
  359. row = row.copy()
  360. row["measurement_name"] = "eeg-power"
  361. row["measurement_units"] = "V^2"
  362. row["timeseries"] = (fs, ts, power)
  363. return row
  364. eeg_df = deepcopy(data.query("measurement_name == 'eeg'")) #brand new copy so that I can pass by object (instead of by reference) and do not alter the original data
  365. data_v2 = pd.concat([data, eeg_df.apply(compute_spectrogram, axis=1)])
  366. data_v2
  367. # %%
  368. data_and_bands = deepcopy(data_v2)
  369. eeg_window = 8 #20
  370. band_names = ['subdelta', 'delta', 'theta']
  371. for d, m, c in anes_data_keys:
  372. fs, ts, power = data_and_bands.query("mouse == @m & date == @d & condition == @c & measurement_name == 'eeg-power'")["timeseries"].values[0]
  373. bands = compute_power_bands(fs, power)
  374. for i_b, _b in enumerate(bands):
  375. bands_dict = {'date': d,
  376. 'mouse': m,
  377. 'condition': c,
  378. 'measurement_group': 'ephys',
  379. 'measurement_name': band_names[i_b],
  380. 'measurement_units': 'V^2',
  381. 'measurement_type': 'ephys',
  382. #Save power band values and actual time together
  383. 'timeseries': (z_score(moving_avg_filter(_b, window = eeg_window, stride = 1)),
  384. ts[eeg_window:-eeg_window]),
  385. 'sample_rate': data_and_bands.query("mouse == @m & date == @d & condition == @c & measurement_name == 'eeg-power'")["sample_rate"].values[0],
  386. 'lag_time': data_and_bands.query("mouse == @m & date == @d & condition == @c & measurement_name == 'eeg-power'")["sample_rate"].values[0]}
  387. data_and_bands = pd.concat([data_and_bands, pd.DataFrame.from_records([bands_dict])])
  388. data_and_bands
  389. # %%
  390. print('Example mouse B48, date 20240917, condition anes.')
  391. print('Number of raw EEG samples: ', data.query("mouse == 'B48' & date == '20240917' & measurement_name == 'eeg' & condition == 'anes'")["timeseries"].values[0].shape)
  392. print('Sample rate: ', data.query("mouse == 'B48' & date == '20240917' & measurement_name == 'eeg' & condition == 'anes'")["sample_rate"].values[0])
  393. print('Number of power EEG samples: ', data_v2.query("mouse == 'B48' & date == '20240917' & measurement_name == 'eeg-power' & condition == 'anes'")["timeseries"].values[0][1].shape)
  394. print('The EEG signal was downsampled by: ',
  395. data.query("mouse == 'B48' & date == '20240917' & measurement_name == 'eeg' & condition == 'anes'")["timeseries"].values[0].shape[0]/
  396. data_v2.query("mouse == 'B48' & date == '20240917' & measurement_name == 'eeg-power' & condition == 'anes'")["timeseries"].values[0][1].shape[0])
  397. new_sr = data.query("mouse == 'B48' & date == '20240917' & measurement_name == 'eeg' & condition == 'anes'")["sample_rate"].values[0]/(data.query("mouse == 'B48' & date == '20240917' & measurement_name == 'eeg' & condition == 'anes'")["timeseries"].values[0].shape[0]/
  398. data_v2.query("mouse == 'B48' & date == '20240917' & measurement_name == 'eeg-power' & condition == 'anes'")["timeseries"].values[0][1].shape[0])
  399. print('Therefore the new sampling rate is: ', new_sr)
  400. print('And first three timepoints of EEG power are found at: ', data_v2.query("mouse == 'B48' & date == '20240917' & measurement_name == 'eeg-power' & condition == 'anes'")["timeseries"].values[0][1][0:3], 'seconds')
  401. print('\nDownsampling by the spectrum function is more or less constant across mice and sessions:')
  402. print(data.query("mouse == 'B48' & date == '20240917' & measurement_name == 'eeg' & condition == 'anes'")["timeseries"].values[0].shape[0]/
  403. data_v2.query("mouse == 'B48' & date == '20240917' & measurement_name == 'eeg-power' & condition == 'anes'")["timeseries"].values[0][1].shape[0])
  404. print(data.query("mouse == 'B47' & date == '20240918' & measurement_name == 'eeg' & condition == 'anes'")["timeseries"].values[0].shape[0]/
  405. data_v2.query("mouse == 'B47' & date == '20240918' & measurement_name == 'eeg-power' & condition == 'anes'")["timeseries"].values[0][1].shape[0])
  406. # %%
  407. cam_sr = 100
  408. eeg_power_sr = {}
  409. ds_factor = {}
  410. for date, mouse, _ in anes_data_keys:
  411. eeg_power_sr[mouse, date] = data_v2.query("mouse == @mouse & date == @date & measurement_name == 'eeg' & condition == 'anes'")["sample_rate"].values[0]/(
  412. data_v2.query("mouse == @mouse & date == @date & measurement_name == 'eeg' & condition == 'anes'")["timeseries"].values[0].shape[0]/
  413. data_v2.query("mouse == @mouse & date == @date & measurement_name == 'eeg-power' & condition == 'anes'")["timeseries"].values[0][1].shape[0])
  414. ds_factor[mouse, date] = int(np.floor(cam_sr / eeg_power_sr[mouse, date])) # camera sample rate / eeg power sample rate
  415. ds_factor
  416. # %% [markdown]
  417. # ## Figure 3c
  418. # %% [markdown]
  419. # (notebook: 2024-12-05-long-anes-three-way-predictions-eeg.ipynb)
  420. # %% [markdown]
  421. # Overlaid z-scored ear angle, eye height, and nose bulge volume traces from 12 sessions (n = 3 mice, four sessions per mouse. Vertical scale bar indicates one standard deviation).
  422. # %%
  423. # f_window = 1000 #1000
  424. features = ['ear-angle-left', 'eye-height-left', 'nose-bulge-volume']
  425. fig, axs = plt.subplots(nrows=len(features),
  426. ncols=1,
  427. figsize=(3*3, 4*3),
  428. dpi=200,
  429. constrained_layout=False)
  430. meas_summary = {}
  431. time_summary = {}
  432. for row, l in enumerate(features):
  433. meas_summary[l] = []
  434. time_summary[l] = []
  435. y_labels = []
  436. for sess in anes_data_keys:
  437. mouse = sess[1]
  438. date = sess[0]
  439. if sess in anes_data_keys:
  440. anes_start = np.max(list(ANES_START_OFFSETS.values())) - ANES_START_OFFSETS[date, mouse] #in seconds - Take the same start for all of them
  441. ys = data_v2.query(
  442. "mouse == @mouse & "
  443. "date == @date & "
  444. "condition == 'anes' & "
  445. "measurement_name == @l"
  446. )["timeseries"].values[0]
  447. anes_end = int(np.floor((45 * 60 - ANES_START_OFFSETS[date, mouse]))) * 100 # Trim at 45 minutes since anesthesia injection
  448. ys = ys[int(np.floor(anes_start)*100):anes_end]
  449. time = (np.arange(len(ys))/100 + np.max(list(ANES_START_OFFSETS.values())))/ 60 #Note all will start at 5 min and camera sample rate is 100
  450. time = time[FILTER_WINDOW:-FILTER_WINDOW]
  451. # FILTER AND Z SCORED DATA PER MEASUREMENT
  452. ys = moving_avg_filter(ys, FILTER_WINDOW)
  453. ys = z_score(ys)
  454. meas_summary[l].append(ys)
  455. time_summary[l].append(time)
  456. sns.lineplot(x = time, y = ys,
  457. color = CONTROL_CMAP_V2[0], linewidth = 0.5, alpha = 0.5,
  458. ax = axs[row])
  459. sns.lineplot(x = time, y = np.mean(meas_summary[l], axis = 0),
  460. color = CONTROL_CMAP_V2[0], linewidth = 2.5,
  461. ax = axs[row])
  462. axs[row].sharex(axs[0])
  463. axs[row].set_ylabel(('\n').join(l.split('-')), rotation = 0)
  464. axs[row].yaxis.set_label_coords(-0.1,0.5)
  465. if row != (len(features)-1):
  466. axs[row].tick_params("x", bottom=False, labelbottom=False)
  467. sns.despine(ax=axs[row], bottom=True)
  468. axs[row].set_xlabel('Time (s)')
  469. axs[row].set_xticks(np.arange(0, 45, 10))
  470. sns.despine(ax=axs[row])
  471. fig.tight_layout()
  472. fname = "3c-facial-feat-3subset-allmice-10s-avgfilter"
  473. fig.savefig(os.sep.join([OUTPUT_DIR, f"{fname}.svg"]), bbox_inches="tight")
  474. # %% [markdown]
  475. # ## Figure 3d
  476. # %% [markdown]
  477. # (notebook: 2024-08-30-eeg-emg-long-anes.ipynb)
  478. # %% [markdown]
  479. # Simultaneously recorded Cheese3D features (top, showing moving average over a 10 sec window; vertical scale bars: ear: 2◦, eye: 0.1mm, nose: 0.5mm3), EEG spectrogram (middle, 5sec FFT window), and power of EEG frequency bands (bottom, showing subdelta: 0.2 Hz to 1 Hz, delta: 1 Hz to 4 Hz, and theta: 5 Hz to 10 Hz bands; vertical scale bars indicate one standard deviation) for an example session.
  480. # %%
  481. max_power = np.max(data_v2.query("measurement_name == 'eeg-power'").apply(
  482. lambda x: np.max(x["timeseries"][-1]),
  483. axis=1
  484. ).values)
  485. min_power = np.min(data_v2.query("measurement_name == 'eeg-power'").apply(
  486. lambda x: np.min(x["timeseries"][-1]),
  487. axis=1
  488. ).values)
  489. def plot_aligned_helper(dates, mice, conditions, style, timeseries, lags, sample_rate, color):
  490. anes_start = ANES_START_OFFSETS[(dates.values[0], mice.values[0])] #in seconds
  491. anes_end = -1 #np.floor((45 * 60 - ANES_START_OFFSETS[(dates.values[0], mice.values[0])])) # in seconds - Trim at 45 minutes since anesthesia injection
  492. awake = (conditions == "awake")
  493. if style.values[0] == "eeg-power":
  494. plot_spectrum_helper(dates, mice, conditions, timeseries, lags, color,
  495. min_power=min_power, max_power=max_power)
  496. elif style.values[0] in band_names:
  497. _b = timeseries[~awake].values[0][0] #this has already been filtered and zscored (see cell above)
  498. ts = timeseries[~awake].values[0][1]
  499. sns.lineplot(x=(ts[:anes_end]+anes_start)/60,
  500. y=_b[:anes_end])
  501. else:
  502. ys = timeseries[~awake].values[0]
  503. ys = ys[:anes_end]
  504. xs = np.arange(len(ys)) / sample_rate[~awake].values[0]
  505. xs = xs + anes_start #+ awake_end
  506. sns.lineplot(x=xs[FILTER_WINDOW:-FILTER_WINDOW] / 60,
  507. y=moving_avg_filter(ys, FILTER_WINDOW),
  508. color=CONTROL_CMAP[2],
  509. label="anes")
  510. measurements = ["ear-angle-left",
  511. "eye-height-left",
  512. "nose-bulge-volume",
  513. "eeg-power",
  514. "theta",
  515. "delta",
  516. "subdelta",
  517. ]
  518. for d in set(data_and_bands["date"]):
  519. if d == '20240822':
  520. continue
  521. g = sns.FacetGrid(data_and_bands.query("date == @d & "
  522. "measurement_name in @measurements"),
  523. row="measurement_name", col="mouse",
  524. row_order=measurements,
  525. sharey=False,
  526. sharex=False,
  527. # xlim=(0, 70),
  528. aspect=2,
  529. height=2)
  530. g.map(plot_aligned_helper,
  531. "date", "mouse", "condition",
  532. "measurement_name", "timeseries", "lag_time", "sample_rate")
  533. cbar_ax = g.figure.add_axes([1.015, 0.44, 0.015, 0.12]) #[1.015, 0.19, 0.015, 0.12]
  534. plt.colorbar(cax=cbar_ax)
  535. g.figure.suptitle(f"Anesthetized Spectrogram ({d})")
  536. g.set_xlabels("Time (min)")
  537. g.set_ylabels("")
  538. sns.despine(g.figure)
  539. for ax in g.axes.flat:
  540. # if ax.get_xbound()[1] > 65:
  541. # ax.set_xticks(np.arange(0, 71, 10))
  542. # else:
  543. ax.set_xticks(np.arange(0, 45, 10))
  544. ax.set_xlim(0, 45)
  545. for ax in g.axes[-4, :]: # Limit for the EEG spectrogram
  546. ax.set_ylim(0, 20)
  547. for ax in g.axes[:-1, :].flat: # Remove x-ticks except in bottom plot
  548. ax.set_xticks([])
  549. sns.despine(ax=ax, bottom=True)
  550. # for ax in g.axes[-1, :]:
  551. # axmin = min(ax.get_xlim()[0], ax.get_ylim()[0])
  552. # axmax = max(ax.get_xlim()[1], ax.get_ylim()[1])
  553. # ax.plot(np.linspace(axmin, axmax), np.linspace(axmin, axmax), color='black', linestyle="--")
  554. # ax.set_aspect('equal', anchor = 'SW', adjustable = 'box')
  555. g.set_titles(template="{col_name} ({row_name})")
  556. g.tight_layout()
  557. fname = f"3d-fe-and-eeg-exemplar-{d}"
  558. # g.figure.savefig(os.sep.join([OUTPUT_DIR, f"{fname}.svg"]), bbox_inches="tight")
  559. # %% [markdown]
  560. # ## Figure 3e
  561. # %% [markdown]
  562. # (notebook: 2024-12-05-long-anes-three-way-predictions-eeg)
  563. # %% [markdown]
  564. # Output from a quadratic model fit across mice predicting time since injection using the initial and current Cheese3D (orange) or EEG (blue) feature values relative to the dotted identity line.
  565. # %%
  566. EEG_MODEL_NAME = os.sep.join([DATA_CACHE, "lasso-model-mouse-sess-eeg-220runs-interact-stride-init-bias-45minend-Jan30-2ndfix.pkl"])
  567. FF_MODEL_NAME = os.sep.join([DATA_CACHE, "lasso-model-mouse-sess-220runs-interact-stride-init-bias-45minend-Jan30-2ndfix.pkl"])
  568. results_eeg_df_name = 'measurements-data-cache-2024/prediction-eeg-results-220runs-interact-stride-init-bias-45minend-Jan30-2ndfix.pkl'
  569. results_df_name = 'measurements-data-cache-2024/prediction-results-220runs-interact-stride-init-bias-45minend-Jan30-2ndfix.pkl'
  570. # %% [markdown]
  571. # Note that the names above correspond to the files used for the manuscript results. If you would like to use a different model, you will need to change the specified names.
  572. #
  573. # Comment the cell below if you would like to run the model code.
  574. # %%
  575. # These are the models and predictions used in the manuscript
  576. # EEG models and results
  577. if os.path.exists(EEG_MODEL_NAME):
  578. with open(EEG_MODEL_NAME, "rb") as fio:
  579. models_eeg = pickle.load(fio) # Model results
  580. if os.path.exists(results_eeg_df_name):
  581. results_eeg_df = pd.read_pickle(results_eeg_df_name) # Prediction results
  582. # Facial feature models
  583. if os.path.exists(FF_MODEL_NAME):
  584. with open(FF_MODEL_NAME, "rb") as fio:
  585. models_ff = pickle.load(fio)
  586. if os.path.exists(results_eeg_df_name):
  587. results_df = pd.read_pickle(results_df_name)
  588. # %% [markdown]
  589. # ### EEG model
  590. # %%
  591. # def run_regression(ts_df, anes_data_keys, features, train_idx, alpha_range, models = None):
  592. def run_regression(ts_df, anes_data_keys, alpha_range):
  593. # specify feature parameters
  594. # 20 filter window in samples ~ 40 seconds if nperseg = 2000;
  595. # 8 filter window in samples ~ 40 seconds if nperseg = 5000
  596. # refer to prints about eeg-power sample rate in cell above)
  597. eeg_window = 8
  598. # Polynomial features
  599. degree = 2 #degree of the polynomial
  600. lag = 1
  601. # define empty lists to generate data for model
  602. measures = []
  603. target = []
  604. indeces = []
  605. counter = 0
  606. for s_idx, dk in enumerate(anes_data_keys):
  607. d = dk[0]
  608. mouse = dk[1]
  609. # Select input features: eeg-power bands
  610. eegpower = ts_df.query("mouse == @mouse & date == @d & condition == 'anes' & measurement_name == 'eeg-power'")["timeseries"]
  611. fs, ts, power = eegpower.values[0]
  612. _bands = compute_power_bands(fs, power) #type(_bands) = tuple
  613. # Define actual time
  614. anes_start = ANES_START_OFFSETS_EEG[(d, mouse)]
  615. anes_end = int(np.floor((45 * 60 - anes_start) * eeg_power_sr[mouse, d])) # Trim at 45 minutes since anesthesia injection
  616. _time = ts[:anes_end]
  617. target.append((_time[eeg_window:-eeg_window] + anes_start) / 60) #lag is only for plotting comparisons against facial features?
  618. # Filter noise
  619. bands =[]
  620. for _b in _bands:
  621. bands.append(moving_avg_filter(_b[:anes_end], window = eeg_window, stride = 1))
  622. # Z-score and get features
  623. _measures = np.concatenate((z_score(bands[0][:,na]), #subdelta
  624. z_score(bands[1][:,na]), #delta
  625. z_score(bands[2][:,na])), 1) #theta
  626. _measures = get_features(_measures, degree, lag, interaction = True, initial_bias = True)
  627. _measures[:,0] = 1
  628. # Save indeces in a list of arrays corresponding to each session
  629. indeces.append(np.arange(_measures.shape[0]) + counter)
  630. counter += _measures.shape[0]
  631. measures.append(_measures)
  632. measures = np.concatenate(measures, axis=0)
  633. time = np.concatenate(target, axis=0)
  634. cv = []
  635. # Annotate indeces to separate into validation and train sets
  636. for k in range(len(indeces)):
  637. train_indeces = np.concatenate([indeces[i] for i in range(len(indeces)) if i != k])
  638. test_indeces = indeces[k] #Leave one session out for validation (a.k.a. test within the train set)
  639. cv.append((train_indeces, test_indeces))
  640. print(f"Running regression for (subdelta, delta, supdelta)...")
  641. # fit Lasso model and explore alpha space
  642. mdl = Lasso(fit_intercept=True)
  643. param_grid = {'alpha': alpha_range} #key must be the same name that is used in Lasso documentation
  644. model = GridSearchCV(mdl, param_grid, cv = cv, verbose = 0, n_jobs = 8, scoring = "neg_root_mean_squared_error") # Explore hyper-parameter space (only for alpha in this case)
  645. model.fit(measures[:,1:], time)
  646. return model
  647. # Explore alpha space below:
  648. alpha_range = np.logspace(-1, 2, 100)
  649. n_runs = 220 #This number should match the len(all_combos) below
  650. n_test = 3 #number of test sessions
  651. test_heldout =[]
  652. all_combos = list(itertools.combinations(range(len(anes_data_keys)), n_test)) #all possible combinations
  653. if os.path.exists(EEG_MODEL_NAME):
  654. print("Manuscript model has been loaded.")
  655. elif os.path.exists(LASSO_MODEL_EEG):
  656. print(f"{LASSO_MODEL_EEG} has been loaded.")
  657. with open(LASSO_MODEL_EEG, "rb") as fio:
  658. models_eeg = pickle.load(fio) # Model results
  659. else:
  660. models_eeg = []
  661. for r in np.arange(n_runs):
  662. test_heldout_idx = all_combos[r]
  663. _test_heldout = [d for idx, d in enumerate(anes_data_keys) if idx in test_heldout_idx]
  664. test_heldout.append(_test_heldout)
  665. anes_data_keys_train = [d for d in anes_data_keys if d not in _test_heldout]
  666. models_eeg.append(run_regression(data_v2, anes_data_keys_train, alpha_range))
  667. with open(LASSO_MODEL_EEG, "wb") as fio:
  668. pickle.dump(models_eeg, fio)
  669. # %%
  670. def get_results_df(data, model, anes_data_keys, test_heldout, n_run = 0):
  671. eeg_window = 8
  672. results = []
  673. for d in anes_data_keys:
  674. date = d[0]
  675. mouse = d[1]
  676. anes_start = ANES_START_OFFSETS_EEG[(date, mouse)] # in seconds
  677. anes_end = int(np.floor((45 * 60 - anes_start) * eeg_power_sr[mouse, date])) # Trim at 45 minutes since anesthesia injection
  678. # Raw eeg/power data
  679. eegpower = data.query("mouse == @mouse & date == @date & condition == 'anes' & measurement_name == 'eeg-power'")["timeseries"]
  680. fs, ts, power = eegpower.values[0]
  681. _bands = compute_power_bands(fs, power) #type(_bands) = tuple
  682. # Filter eeg power data
  683. bands =[]
  684. for _b in _bands:
  685. bands.append(moving_avg_filter(_b[:anes_end], window = eeg_window, stride = 1))
  686. # Z-score and get features
  687. measures = np.concatenate((z_score(bands[0][:,na]), #subdelta
  688. z_score(bands[1][:,na]), #delta
  689. z_score(bands[2][:,na])), 1) #theta
  690. test_ts = get_features(measures, degree = 2, lags = 1, interaction = True, initial_bias = True)
  691. test_ts[:,0] = 1
  692. # Define actual time
  693. time = ts[:anes_end]
  694. time = (time[eeg_window:-eeg_window] + anes_start) / 60 #lag is only for plotting comparisons against facial features?
  695. # predict time
  696. times_hat = model.predict(test_ts[:,1:])
  697. # Compute RMSE
  698. rmse = np.sqrt(np.mean((times_hat - time) ** 2))
  699. # Save results
  700. set_group = "Test" if (date, mouse, 'anes') in test_heldout else "Train"
  701. results.append([n_run, mouse, date, set_group, "(subdelta, delta, theta)", times_hat, time, rmse])
  702. results_df = pd.DataFrame(results, columns = ["run",
  703. "mouse",
  704. "date",
  705. "set",
  706. "features",
  707. "predicted time",
  708. "actual time",
  709. "RMSE"])
  710. return results_df
  711. _results_df = []
  712. PREDICTION_RESULTS_EEG_DF = os.sep.join([DATA_CACHE, f'{TODAY}-prediction-eeg-results.pkl'])
  713. if os.path.exists(results_eeg_df_name):
  714. print("Manuscript prediction results have been loaded.")
  715. elif os.path.exists(PREDICTION_RESULTS_EEG_DF):
  716. print(f"{PREDICTION_RESULTS_EEG_DF} prediction results have been loaded.")
  717. results_eeg_df = pd.read_pickle(PREDICTION_RESULTS_EEG_DF) # Prediction results
  718. else:
  719. for r in np.arange(n_runs):
  720. _results_df.append(get_results_df(data_v2, models_eeg[r], anes_data_keys, test_heldout[r], r))
  721. results_eeg_df = pd.concat(_results_df)
  722. pd.to_pickle(pd.DataFrame(results_eeg_df), PREDICTION_RESULTS_EEG_DF)
  723. # %% [markdown]
  724. # ### Facial features model
  725. # %%
  726. # def run_regression(ts_df, anes_data_keys, features, train_idx, alpha_range, models = None):
  727. def run_regression(ts_df, anes_data_keys, features, alpha_range):
  728. # specify feature parameters
  729. f_window = 1000 #6000 #In samples ; For moving_avg_filter the behavior features
  730. degree = 2 #3 #of the polynomial
  731. lag = 1
  732. # define empty lists to generate data for model
  733. measures = []
  734. time = []
  735. # data query for input features to the model
  736. meas_name_query = " | ".join(f"measurement_name == '{feature}'" for feature in features)
  737. indeces = []
  738. counter = 0
  739. for s_idx, dk in enumerate(anes_data_keys):
  740. d = dk[0]
  741. mouse = dk[1]
  742. anes_end = int(45 * 60 - ANES_START_OFFSETS[d, mouse])*100 # Trim at 45 minutes since anesthesia injection
  743. _measures = ts_df.query("mouse == @mouse & date == @d & condition == 'anes' & measurement_group != 'ephys' & "
  744. "(" + meas_name_query + ")").sort_values("measurement_name")["timeseries"].values
  745. # Filter noise
  746. for _ncol, _m in enumerate(_measures):
  747. _measures[_ncol] = moving_avg_filter(_m[:anes_end], f_window, stride = ds_factor[mouse, d])
  748. # Z-score
  749. _measures = z_score(np.stack(_measures, axis=1))
  750. _measures = get_features(_measures, degree, lag, interaction = True, initial_bias = True)
  751. _measures[:,0] = 1 #bias should be unaffected by zscoring and thus always 1
  752. # Save indeces in a list of arrays corresponding to each session
  753. indeces.append(np.arange(_measures.shape[0]) + counter)
  754. counter += _measures.shape[0]
  755. measures.append(_measures)
  756. # Define actual time
  757. ys = ts_df.query(
  758. "mouse == @mouse & "
  759. "condition == 'anes' & "
  760. "date == @d & "
  761. "measurement_name == @features[0]"
  762. )["timeseries"].values[0]
  763. _time = (np.arange(len(ys))/100 + ANES_START_OFFSETS[d, mouse])/ 60 #Note camera sample rate is 100
  764. _time = _time[:anes_end]
  765. _time = _time[f_window:-f_window]
  766. time.append(_time[::ds_factor[mouse, d]])
  767. measures = np.concatenate(measures, axis=0)
  768. time = np.concatenate(time, axis=0)
  769. cv = []
  770. # Annotate indeces to separate into validation and train sets
  771. for k in range(len(indeces)):
  772. train_indeces = np.concatenate([indeces[i] for i in range(len(indeces)) if i != k])
  773. test_indeces = indeces[k] #Leave one out cross-validation (a.k.a. test within the train set)
  774. cv.append((train_indeces, test_indeces))
  775. print(f"Running regression for {features}...")
  776. # fit Lasso model and explore alpha space
  777. mdl = Lasso(fit_intercept=True)
  778. param_grid = {'alpha': alpha_range} #key must be the same name that is used in Lasso documentation
  779. model = GridSearchCV(mdl, param_grid, cv = cv, verbose = 0, n_jobs = 6, scoring = "neg_root_mean_squared_error") # Explore hyper-parameter space (only for alpha in this case)
  780. model.fit(measures[:,1:], time)
  781. return model
  782. # Select facial features as input to the model
  783. # features = list(set(data_v2.query("measurement_group != 'ephys'")["measurement_name"]))
  784. features = ['eye-height-left', 'ear-angle-left', 'nose-bulge-volume']
  785. # Explore alpha space below:
  786. alpha_range = np.logspace(-1, 2, 100)
  787. n_runs = 220
  788. n_test = 3 #number of test sessions
  789. test_heldout =[]
  790. all_combos = list(itertools.combinations(range(len(anes_data_keys)), n_test))
  791. if os.path.exists(FF_MODEL_NAME):
  792. print("Manuscript model has been loaded.")
  793. elif os.path.exists(LASSO_MODEL_FF):
  794. print(f"{LASSO_MODEL_FF} has been loaded.")
  795. with open(LASSO_MODEL_FF, "rb") as fio:
  796. models_ff = pickle.load(fio) # Model results
  797. else:
  798. models_ff = []
  799. for r in np.arange(n_runs):
  800. test_heldout_idx = all_combos[r] #np.random.permutation(len(anes_data_keys))[:n_test]
  801. _test_heldout = [d for idx, d in enumerate(anes_data_keys) if idx in test_heldout_idx]
  802. print(test_heldout_idx, _test_heldout)
  803. test_heldout.append(_test_heldout)
  804. anes_data_keys_train = [d for d in anes_data_keys if d not in _test_heldout]
  805. models_ff.append(run_regression(data_v2, anes_data_keys_train, features, alpha_range))
  806. with open(LASSO_MODEL_FF, "wb") as fio:
  807. pickle.dump(models_ff, fio)
  808. # %%
  809. def get_results_df(data, model, anes_data_keys, test_heldout, features, ds_factor, n_run = 0):
  810. results = []
  811. # ANES_END = 10 * 60 * 100
  812. # These should match measurements used as input features
  813. meas_name_query = " | ".join(f"measurement_name == '{feature}'" for feature in features)
  814. for d in anes_data_keys:
  815. date = d[0]
  816. mouse = d[1]
  817. anes_end = int(45 * 60 - ANES_START_OFFSETS[date, mouse]) * 100# Trim at 45 minutes since anesthesia injection
  818. test_ts = data.query("date == @date & mouse == @mouse & condition == 'anes' & "
  819. "measurement_group != 'ephys' & "
  820. "(" + meas_name_query + ")").sort_values("measurement_name")["timeseries"].values
  821. # Filter noise
  822. f_window = 1000 #6000 # should be the same as the one used for the regression input features
  823. for _ncol, _m in enumerate(test_ts):
  824. test_ts[_ncol] = moving_avg_filter(_m[:anes_end], f_window, stride = ds_factor[mouse, date])
  825. # Z-score
  826. test_ts = z_score(np.stack(test_ts, axis=1))
  827. test_ts = get_features(test_ts, degree = 2, lags = 1, interaction = True, initial_bias = True)
  828. # test_ts = z_score(test_ts)
  829. test_ts[:,0] = 1
  830. # Define actual time
  831. ys = data.query(
  832. "mouse == @mouse & "
  833. "date == @date &"
  834. "condition == 'anes' & "
  835. "measurement_name == @features[0]"
  836. )["timeseries"].values[0]
  837. time = (np.arange(len(ys))/100 + ANES_START_OFFSETS[date, mouse])/ 60 #Note camera sample rate is 100
  838. time = time[:anes_end]
  839. time = time[f_window:-f_window]
  840. time = time[::ds_factor[mouse, date]]
  841. # predict time
  842. # m_idx = [0 if mouse == 'B47' else 1 if mouse == 'B48' else 2]
  843. # model = models[m_idx[0]]
  844. times_hat = model.predict(test_ts[:,1:])
  845. # Compute RMSE
  846. rmse = np.sqrt(np.mean((times_hat - time) ** 2))
  847. # save results
  848. set_group = "Test" if (date, mouse, 'anes') in test_heldout else "Train"
  849. results.append([n_run, mouse, date, set_group, "whole-face", times_hat, time, rmse])
  850. results_df = pd.DataFrame(results, columns = ["run",
  851. "mouse",
  852. "date",
  853. "set",
  854. "features",
  855. "predicted time",
  856. "actual time",
  857. "RMSE"])
  858. return results_df
  859. _results_df = []
  860. PREDICTION_RESULTS_FF_DF = f'{TODAY}-prediction-ff-results.pkl'
  861. if os.path.exists(results_df_name):
  862. print("Manuscript model has been loaded.")
  863. elif os.path.exists(PREDICTION_RESULTS_FF_DF):
  864. print(f"{PREDICTION_RESULTS_FF_DF} has been loaded.")
  865. results_df = pd.read_pickle(PREDICTION_RESULTS_FF_DF) # Prediction results
  866. else:
  867. for r in np.arange(n_runs):
  868. _results_df.append(get_results_df(data_v2, models_ff[r], anes_data_keys, test_heldout[r], features, ds_factor, r))
  869. results_df = pd.concat(_results_df)
  870. pd.to_pickle(pd.DataFrame(results_df), PREDICTION_RESULTS_FF_DF)
  871. # %% [markdown]
  872. # ### figure
  873. # %%
  874. ex_mouse = 'B47'
  875. ex_date = '20240912'
  876. ex_run = [5, 7] #[face, eeg] #215
  877. fig, ax = plt.subplots(nrows= 1,
  878. ncols= 1,
  879. figsize=(6,6),
  880. dpi=200,
  881. constrained_layout=True)
  882. plot_predictions(results_eeg_df.query("run == @ex_run[1] & mouse == @ex_mouse & date == @ex_date")["actual time"],
  883. results_eeg_df.query("run == @ex_run[1] & mouse == @ex_mouse & date == @ex_date")["predicted time"],
  884. FILTER_WINDOW = 10,
  885. color = EEG_CONTROL_CMAP[0],
  886. label = 'eeg')
  887. plot_predictions(results_df.query("run == @ex_run[0] & mouse == @ex_mouse & date == @ex_date")["actual time"],
  888. results_df.query("run == @ex_run[0] & mouse == @ex_mouse & date == @ex_date")["predicted time"],
  889. FILTER_WINDOW = 10,
  890. color = CONTROL_CMAP[2],
  891. label = 'facial features',
  892. dash_line = False)
  893. ax.set(title = f"{ex_mouse} ({ex_date})",
  894. xlabel = "Actual time (min)", ylabel = "Predicted Time (min)",
  895. xticks=[0, 10, 20, 30, 40, 50], yticks=[0, 10, 20, 30, 40, 50],
  896. xlim = [0, 50], ylim = [0, 50])
  897. ax.set_aspect("equal", "box")
  898. sns.despine()
  899. fname = f"3e-eeg-vs-ff-regression-ex-{ex_mouse}-{ex_date}"
  900. fig.savefig(os.sep.join([OUTPUT_DIR, f"{fname}.svg"]), bbox_inches="tight")
  901. # %% [markdown]
  902. # ### statistics
  903. # %%
  904. # Save the mean RMSE results into .csv files
  905. # mean_results_ff_df = test_mean_per_run(results_eeg_df, 220, "(subdelta, delta, theta)")
  906. # mean_results_eeg_df = test_mean_per_run(results_df, 220, "whole-face)")
  907. # mean_results_ff_df.to_csv(f'measurements-data-cache/CSVs/mean_results_ff_df_{TODAY}.csv', index=False)
  908. # mean_results_eeg_df.to_csv(f'measurements-data-cache/CSVs/mean_results_eeg_df_{TODAY}.csv', index=False)
  909. # %%
  910. def cv_ttest_corrected(x, y, kfolds, nrepeats, ntrain, ntest):
  911. diff = x - y
  912. print(f"Length of the diff ({len(diff)}) should be equal to kfolds * nrepeats ({kfolds * nrepeats})")
  913. v = np.sum((diff - np.mean(diff))**2) / (len(diff) - 1) # ~= np.std(diff, ddof=1)
  914. tstat = np.mean(diff) / np.sqrt(v * (1/(kfolds * nrepeats) + ntest/ntrain)) # tstat with correction
  915. pval = stats.t.sf(np.abs(tstat), nrepeats*kfolds - 1) # pvalue with correction
  916. return tstat, pval
  917. # %%
  918. eeg_mean_results_df = test_mean_per_run(results_eeg_df, n_runs, "(subdelta, delta, theta)")
  919. face_mean_results_df = test_mean_per_run(results_df, n_runs, "(subdelta, delta, theta)")
  920. stat = cv_ttest_corrected(face_mean_results_df['mean_RMSE'].values,
  921. eeg_mean_results_df['mean_RMSE'].values,
  922. n_runs, 1, 9, 3)
  923. print(f" tstat = {stat[0]}\n p-value = {stat[1]}")
  924. # %% [markdown]
  925. # ## Figure 3f
  926. # %% [markdown]
  927. # Root-mean-square error (RMSE) of time prediction where each dot represents the mean test error for one particular model trained on either Cheese3D (orange) or EEG (blue) features.
  928. # %%
  929. # Plot the results
  930. fig, ax = plt.subplots(figsize=(3, 5))
  931. # Facial features
  932. n_runs = 220
  933. sns.stripplot(test_mean_per_run(results_df, n_runs, "whole-face"),
  934. x="features", y="mean_RMSE",
  935. hue="features", #date #mouse #run
  936. palette=CONTROL_CMAP_V2,
  937. legend = False,
  938. alpha=0.3, ax=ax)
  939. sns.violinplot(test_mean_per_run(results_df, n_runs, "whole-face"),
  940. x="features", y="mean_RMSE",
  941. hue="features", #date #mouse #run
  942. palette=CONTROL_CMAP_V2,
  943. legend = False,
  944. inner = None,
  945. alpha=0.6, ax=ax)
  946. # EEG power bands
  947. sns.stripplot(test_mean_per_run(results_eeg_df, n_runs, "(subdelta, delta, theta)"),
  948. x="features", y="mean_RMSE",
  949. hue="features",
  950. palette=EEG_CONTROL_CMAP,
  951. legend = False,
  952. alpha=0.3, ax=ax)
  953. sns.violinplot(test_mean_per_run(results_eeg_df, n_runs, "(subdelta, delta, theta)"),
  954. x="features", y="mean_RMSE",
  955. hue="features",
  956. palette=EEG_CONTROL_CMAP,
  957. legend = False,
  958. inner = None,
  959. alpha=0.6, ax=ax)
  960. ax.set_xlabel("Feature Set")
  961. ax.set_ylabel("Root Mean Squared Error (min)")
  962. ax.set_title("Prediction Error Across Feature Sets")
  963. ax.set_xticks(list(range(len(ax.get_xticklabels()))),
  964. [format_feature_ticks(f) for f in ax.get_xticklabels()])
  965. ax.tick_params(axis="x", bottom=False)
  966. ax.set_ylim(0, None)
  967. ax.set_yticks(np.arange(0,16,5))
  968. ax.legend(frameon=False, bbox_to_anchor=(1.0, 0.3))
  969. sns.despine(fig)
  970. fname = f"3f-eeg-vs-face-regression-results-summary-violin"
  971. fig.savefig(os.sep.join([OUTPUT_DIR, f"{fname}.svg"]), bbox_inches="tight")
  972. # %% [markdown]
  973. # ## Figure 3g
  974. # %% [markdown]
  975. # (notebook: 2024-12-05-long-anes-three-way-predictions-eeg)
  976. #
  977. # Overlaid z-scored theta, delta, and sub-delta frequency band power traces from 12 sessions (same as in (c);
  978. # vertical scale bars indicate one standard deviation)
  979. # %%
  980. eeg_window = 8
  981. spacer = 8 #5
  982. features = ["subdelta (<1Hz)", "delta [1-4Hz]", "theta [5-10Hz]"]
  983. scale = 1
  984. fig, axs = plt.subplots(nrows=len(features),
  985. ncols=1,
  986. figsize=(5, 8),
  987. dpi=150,
  988. constrained_layout=True)
  989. meas_summary = {feat: [] for feat in features}
  990. for sess in anes_data_keys:
  991. mouse = sess[1]
  992. date = sess[0]
  993. eegpower = data_v2.query("mouse == @mouse & date == @date & condition == 'anes' & measurement_name == 'eeg-power'")["timeseries"]
  994. fs, ts, power = eegpower.values[0]
  995. bands = compute_power_bands(fs, power) #type(_bands) = tuple
  996. anes_start = np.max(list(ANES_START_OFFSETS_EEG.values())) - ANES_START_OFFSETS_EEG[date, mouse] #in seconds - Take the same start for all of them
  997. anes_start = int(np.floor(anes_start) * eeg_power_sr[mouse, date])
  998. anes_end = 540 #int(np.floor((45 * 60 - anes_start) * eeg_power_sr[mouse, date])) # Trim at 45 minutes since anesthesia injection
  999. for i_b, _b in enumerate(bands):
  1000. # FILTER AND Z SCORED DATA PER MEASUREMENT
  1001. if i_b > 2:
  1002. continue
  1003. shortened_b = _b[anes_start:anes_start + anes_end]
  1004. time = np.arange(0, len(shortened_b))/eeg_power_sr[mouse, date] + np.max(list(ANES_START_OFFSETS_EEG.values()))
  1005. time = (time[eeg_window:-eeg_window])/60
  1006. # print(time[-1], len(time))
  1007. ys = z_score(moving_avg_filter(shortened_b, window = eeg_window, stride = 1))
  1008. sns.lineplot(x = time, y = ys,
  1009. color = EEG_CONTROL_CMAP[0], linewidth = 0.5, alpha = 0.5,
  1010. ax = axs[i_b])
  1011. meas_summary[features[i_b]].append(ys)
  1012. axs[i_b].sharex(axs[0])
  1013. axs[i_b].set_ylabel(('\n').join(features[i_b].split(' ')), rotation = 0)
  1014. axs[i_b].yaxis.set_label_coords(-0.2,0.5)
  1015. if i_b != len(features)-1:
  1016. axs[i_b].tick_params("x", bottom=False, labelbottom=False)
  1017. sns.despine(ax=axs[i_b], bottom=True)
  1018. # axs[col].plot(time[f_window:-f_window], ys - spacer*spacing, color = MEASUREMENT_CMAP[l])
  1019. # plt.plot(time, ys - spacer*spacing, color = MEASUREMENT_CMAP[l])
  1020. for row, feat in enumerate(features):
  1021. sns.lineplot(x = time, y = np.mean(meas_summary[feat], axis=0),
  1022. color = EEG_CONTROL_CMAP[0], linewidth = 2.5, ax = axs[row])
  1023. sns.despine(ax = axs[2])
  1024. axs[len(features)-1].set_xlabel('Time (s)')
  1025. fig.tight_layout()
  1026. # axs[0].set_yticks(np.arange(-spacer,-(spacer*spacing+1), -spacer)) #Negative signs to invert the order of measurements top-bottom
  1027. # axs[0].set_yticklabels(y_labels);
  1028. file_name = "eeg-band-feat-with-mean"
  1029. # save_figure("rev_figs/", f"2024-{file_name}", fig, formats=["svg"])
  1030. # fig.savefig(f'{file_name}_ZSCORED.pdf')
  1031. # %% [markdown]
  1032. # ## Figure 3a
  1033. # %% [markdown]
  1034. # (notebook: 2023-long-anes-measurements.ipynb)
  1035. # %% [markdown]
  1036. # Example facial movement raster plot during anesthesia with concurrent EEG recording (each vertical line cor- responds to movement above the 99.9-th percentile jitter threshold as shown in Supplementary Figure 4c for a given 10 ms time window).
  1037. # %% [markdown]
  1038. # ### load data and functions from 2023 cohort
  1039. # %%
  1040. # For faster development, limit data to X mice
  1041. # Set to a high number to render plots for every mouse
  1042. MAX_NUM_MICE = 100
  1043. # Plot every row
  1044. PLOT_SAMPLE = 100
  1045. # Smooth data for visualization
  1046. SMOOTH_WINDOW_SIZE = 25
  1047. # We didn't measure the amount of time between the end of the awake video and the anesthesia injection
  1048. # This adds a constant offset of 5 minutes for every mouse
  1049. AWAKE_END_OFFSET = 5*60
  1050. # Start of anesthesia video after injection (in seconds)
  1051. ANES_START_OFFSETS = {
  1052. 'B6': 120,
  1053. 'B8': 180,
  1054. 'B15': 60,
  1055. 'B20': 31,
  1056. 'B26': 53,
  1057. 'B33': 30,
  1058. }
  1059. STILL_PERIODS = {
  1060. "B6": (38.50, 44.05),
  1061. "B8": (34.30, 39.55),
  1062. "B15": (27.20, 34.15),
  1063. "B20": (34.55, 41.00),
  1064. "B26": (58.20, 63.28),
  1065. "B33": (44.30, 50.05) #This mouse was commented out in the original notebook (2024-04-05-long-anes-jitter.ipynb)
  1066. # "C3": (0, None)
  1067. }
  1068. _, MEASUREMENT_CMAP = measurements_cmap()
  1069. DATA_CACHE = "measurements-data-cache-2023"
  1070. os.makedirs(DATA_CACHE, exist_ok=True)
  1071. COORD_DATA_CACHE = os.sep.join([DATA_CACHE, "long-anes-coords.pkl"])
  1072. MEASURE_DATA_CACHE = os.sep.join([DATA_CACHE, "long-anes-meas.pkl"])
  1073. JITTER_DATA_CACHE = os.sep.join([DATA_CACHE, "long-anes-jitter-meas.pkl"])
  1074. ANIPOSE_BASE = 'anipose-projects/20231013-long-anes-rig2'
  1075. COORDINATE_PATHS = {}
  1076. key_cols = ('mouse', 'source', 'condition')
  1077. for p in Path(ANIPOSE_BASE).glob('*/pose-3d/*.csv'):
  1078. mouse = p.name.split('_')[1]
  1079. source = 'rig2'
  1080. condition = 'awake' if 'awake' in p.name else 'anes'
  1081. COORDINATE_PATHS[(mouse, source, condition)] = p
  1082. data_keys = list(COORDINATE_PATHS.keys())
  1083. data_keys
  1084. # %%
  1085. def build_measjitter_df(meas_df, periods):
  1086. # select only the subset of rows that match the mouse/source/condition pairs
  1087. queries = [(meas_df["mouse"] == mouse) &
  1088. (meas_df["source"] == "rig2") &
  1089. ((meas_df["condition"] == "anes") | (meas_df["condition"] == "dead"))
  1090. for mouse in periods.keys()]
  1091. sub_df = meas_df[reduce(lambda x, y: x | y, queries)]
  1092. jitter_df = sub_df[[*key_cols,
  1093. "measurement_group",
  1094. "measurement_name",
  1095. "measurement_type",
  1096. "timeseries"]]
  1097. jitter_df = jitter_df.rename(columns={"measurement_group": "region"})
  1098. jitter_df.loc[(jitter_df["region"] == 'eye') &
  1099. (jitter_df["measurement_name"].str.contains('left')), "region"] = "eye(left)"
  1100. jitter_df.loc[(jitter_df["region"] == 'eye') &
  1101. (jitter_df["measurement_name"].str.contains('right')), "region"] = "eye(right)"
  1102. jitter_df.loc[(jitter_df["region"] == 'ear') &
  1103. (jitter_df["measurement_name"].str.contains('left')), "region"] = "ear(left)"
  1104. jitter_df.loc[(jitter_df["region"] == 'ear') &
  1105. (jitter_df["measurement_name"].str.contains('right')), "region"] = "ear(right)"
  1106. jitter_df.loc[(jitter_df["region"] == "cheek"), "region"] = "whisker pad"
  1107. jitter_df.loc[(jitter_df["measurement_name"] == "cheek-bulge-volume"), "measurement_name"] = "cheek-bulge-volume"
  1108. jitter_df.loc[(jitter_df["measurement_name"] == "nose-bulge-volume"), "measurement_name"] = "nose-bulge-volume"
  1109. for mouse, period in periods.items():
  1110. start = round(period[0] * 60 * 100) if period[0] is not None else None
  1111. end = round(period[1] * 60 * 100) if period[1] is not None else None
  1112. idx = jitter_df["mouse"] == mouse
  1113. jitter_df.loc[idx, "timeseries"] = jitter_df.loc[idx, "timeseries"].apply(
  1114. lambda x: x[start:end]
  1115. )
  1116. jitter_df["deviations"] = jitter_df.groupby("measurement_name")["timeseries"].transform(
  1117. lambda x: x.apply(lambda xi: xi - xi.mean())
  1118. )
  1119. jitter_df["timeseries_stddev"] = jitter_df.groupby("measurement_name")["deviations"].transform(
  1120. lambda x: x.apply(np.std)
  1121. )
  1122. jitter_df["min_deviation"] = jitter_df.groupby("measurement_name")["deviations"].transform(
  1123. lambda x: x.apply(np.min)
  1124. )
  1125. jitter_df["max_deviation"] = jitter_df.groupby("measurement_name")["deviations"].transform(
  1126. lambda x: x.apply(np.max)
  1127. )
  1128. jitter_df["velocities"] = jitter_df.groupby("measurement_name")["timeseries"].transform(
  1129. lambda x: x.apply(lambda xi: np.abs(np.diff(xi))) * 100
  1130. )
  1131. jitter_df["velocity_mean"] = jitter_df.groupby("measurement_name")["velocities"].transform(
  1132. lambda x: x.apply(np.mean)
  1133. )
  1134. jitter_df["velocity_thresh"] = jitter_df.groupby("measurement_name")["velocities"].transform(
  1135. lambda x: x.apply(lambda xi: np.percentile(xi, 99.9))
  1136. )
  1137. jitter_df["min_velocity"] = jitter_df.groupby("measurement_name")["velocities"].transform(
  1138. lambda x: x.apply(np.min)
  1139. )
  1140. jitter_df["max_velocity"] = jitter_df.groupby("measurement_name")["velocities"].transform(
  1141. lambda x: x.apply(np.max)
  1142. )
  1143. jitter_df["deviations_au"] = jitter_df.groupby("measurement_name")["timeseries"].transform(
  1144. lambda x: x.apply(lambda xi: (xi - xi.mean()) / xi.mean())
  1145. )
  1146. jitter_df["timeseries_stddev_au"] = jitter_df.groupby("measurement_name")["deviations_au"].transform(
  1147. lambda x: x.apply(np.std)
  1148. )
  1149. jitter_df["min_deviation_au"] = jitter_df.groupby("measurement_name")["deviations_au"].transform(
  1150. lambda x: x.apply(np.min)
  1151. )
  1152. jitter_df["max_deviation_au"] = jitter_df.groupby("measurement_name")["deviations_au"].transform(
  1153. lambda x: x.apply(np.max)
  1154. )
  1155. jitter_df["velocities_au"] = jitter_df.groupby("measurement_name")["timeseries"].transform(
  1156. lambda x: x.apply(lambda xi: np.abs(np.diff(xi)) / np.mean(np.abs(np.diff(xi))))
  1157. )
  1158. jitter_df["velocity_mean_au"] = jitter_df.groupby("measurement_name")["velocities_au"].transform(
  1159. lambda x: x.apply(np.mean)
  1160. )
  1161. jitter_df["min_velocity_au"] = jitter_df.groupby("measurement_name")["velocities_au"].transform(
  1162. lambda x: x.apply(np.min)
  1163. )
  1164. jitter_df["max_velocity_au"] = jitter_df.groupby("measurement_name")["velocities_au"].transform(
  1165. lambda x: x.apply(np.max)
  1166. )
  1167. return jitter_df
  1168. # %%
  1169. from scipy.signal import medfilt
  1170. if not os.path.exists(COORD_DATA_CACHE):
  1171. print(f"Pre-filtering coordinate data and storing in {COORD_DATA_CACHE}...")
  1172. coord_data = {k: read_3d_data(v.parent.parent.as_posix(),
  1173. filter_func=medfilt,
  1174. filter_kwargs={'kernel_size': (SMOOTH_WINDOW_SIZE, 1)})
  1175. for k, v in COORDINATE_PATHS.items()}
  1176. with open(COORD_DATA_CACHE, 'wb') as dict_pkl:
  1177. pickle.dump(coord_data, dict_pkl)
  1178. else:
  1179. with open(COORD_DATA_CACHE, 'rb') as dict_pkl:
  1180. coord_data = pickle.load(dict_pkl)
  1181. if not os.path.exists(MEASURE_DATA_CACHE):
  1182. print(f"Pre-computing measurements data and storing in {MEASURE_DATA_CACHE}...")
  1183. meas_df = compute_measurements_df(coord_data)
  1184. meas_df.to_pickle(MEASURE_DATA_CACHE)
  1185. else:
  1186. meas_df = pd.read_pickle(MEASURE_DATA_CACHE)
  1187. if os.path.exists(JITTER_DATA_CACHE):
  1188. jitter_df = pd.read_pickle((JITTER_DATA_CACHE))
  1189. else:
  1190. jitter_df = build_measjitter_df(meas_df, STILL_PERIODS)
  1191. # raise RuntimeError("Jitter results not pre-computed. Run jitter analysis notebook.")
  1192. # %%
  1193. awake_end = {
  1194. mouse: len(df)
  1195. for (mouse, _, condition), df in coord_data.items()
  1196. if condition == 'awake'
  1197. }
  1198. awake_end
  1199. # %%
  1200. from collections import defaultdict
  1201. mice = list(meas_df["mouse"].unique())
  1202. measurement_names = list(meas_df['measurement_name'].unique())
  1203. measurement_group_names = list(meas_df['measurement_group'].unique())
  1204. measurement_groups = defaultdict(set)
  1205. for measurement_name in measurement_names:
  1206. sub_df = meas_df.query("measurement_name == @measurement_name")
  1207. measurement_groups[sub_df['measurement_group'].iloc[0]].add(sub_df['measurement_name'].iloc[0])
  1208. measurement_groups
  1209. # %%
  1210. from labutils.utils import unzip
  1211. def build_timeseries_df(meas_df, data_keys):
  1212. # select only the subset of rows that match the mouse/source/condition pairs
  1213. queries = [(meas_df["mouse"] == mouse) &
  1214. (meas_df["source"] == source) &
  1215. (meas_df["condition"] == condition)
  1216. for mouse, source, condition in data_keys]
  1217. sub_df = meas_df[reduce(lambda x, y: x | y, queries)]
  1218. # compute velocities and pad
  1219. def _process_timeseries(row):
  1220. v = np.abs(np.diff(row["timeseries"]))
  1221. if row["condition"] == "awake":
  1222. p = np.concatenate([row["timeseries"],
  1223. np.zeros(100 * AWAKE_END_OFFSET)])
  1224. v = np.concatenate([[0], v, np.zeros(100 * AWAKE_END_OFFSET)])
  1225. t = np.arange(-len(p), 0)
  1226. else:
  1227. p = np.concatenate([np.zeros(100 * ANES_START_OFFSETS[row["mouse"]]),
  1228. row["timeseries"]])
  1229. v = np.concatenate([np.zeros(100 * ANES_START_OFFSETS[row["mouse"]] + 1), v])
  1230. t = np.arange(len(p))
  1231. return p, v, t
  1232. positions, velocities, frames = unzip(sub_df.apply(_process_timeseries, axis=1).values)
  1233. # create new dataframe for velocities spread out over time
  1234. ts_df = sub_df[[*key_cols, "measurement_name"]].copy()
  1235. ts_df = ts_df.assign(position=positions, velocity=velocities, frames=frames)
  1236. ts_df = ts_df.explode(["position", "velocity", "frames"])
  1237. ts_df = ts_df.pivot(index=[*key_cols, "frames"],
  1238. columns="measurement_name",
  1239. values=["position", "velocity"])
  1240. ts_df.columns = ["/".join(multicol).strip() for multicol in ts_df.columns.values]
  1241. ts_df.reset_index(inplace=True)
  1242. ts_df = ts_df.assign(**{"time (s)": ts_df["frames"] / 100,
  1243. "time (min)": ts_df["frames"] / 100 / 60})
  1244. for name in measurement_names:
  1245. ts_df[f"velocity/{name}-std"] = ts_df.groupby("mouse")[f"velocity/{name}"].transform(
  1246. lambda x: x / x.std()
  1247. )
  1248. ts_df.sort_values(["mouse", "frames"], inplace=True)
  1249. return ts_df
  1250. ts_df = build_timeseries_df(meas_df, data_keys)
  1251. ts_df
  1252. # %% [markdown]
  1253. # ### figure
  1254. # %%
  1255. PLOT_SAMPLE = 1
  1256. time_col = "time (min)"
  1257. plot_meas = ["velocity/ear-angle-left",
  1258. "velocity/ear-angle-right",
  1259. "velocity/eye-area-left",
  1260. "velocity/eye-area-right",
  1261. "velocity/mouth-area",
  1262. "velocity/cheek-bulge-volume",
  1263. "velocity/nose-bulge-volume"]
  1264. cols = [time_col] + plot_meas
  1265. column_order = [time_col] + plot_meas
  1266. fig, axs = plt.subplots(nrows=len(mice),
  1267. # sharex=True,
  1268. figsize=(10, 3 * len(mice)))
  1269. for mouse, ax in zip(filter(lambda x: x != "B33", mice), axs):
  1270. # get plotting subset
  1271. sub_df = ts_df.query('mouse == @mouse')[cols].copy()
  1272. sub_df = sub_df[column_order]
  1273. sub_df.set_index(time_col, inplace=True)
  1274. sub_df = sub_df.iloc[::PLOT_SAMPLE, :]
  1275. # ax = axs[ax_num]
  1276. for i, column in enumerate(plot_meas):
  1277. meas_name = column.split("/")[-1]
  1278. baseline = jitter_df.query("mouse == @mouse & "
  1279. "measurement_name == @meas_name")["velocities"]
  1280. baseline = baseline.values[0] / 100 # convert mm / s -> mm / frame
  1281. thresh = np.percentile(baseline, 99.9)
  1282. # thresh = np.std(baseline)
  1283. print(mouse, meas_name, "threshold =", thresh)
  1284. ticks = sub_df.index[sub_df[column] > thresh]
  1285. # Plot vertical lines for threshold crossing events
  1286. if len(ticks) > 0:
  1287. ax.vlines(ticks, i, i - 1,
  1288. colors=MEASUREMENT_CMAP[meas_name],
  1289. alpha=0.01,
  1290. linewidths=0.5)
  1291. ax.set_title(mouse)
  1292. ax.tick_params(axis="y", left=False)
  1293. ax.set_yticklabels([])
  1294. ax.get_xaxis().set_ticks_position('bottom')
  1295. sns.despine(fig, left=True, top=True, right=True)
  1296. fig.tight_layout()
  1297. fname = f"3a-long-anes-tick-rasters-allmice"
  1298. # fig.savefig(os.sep.join([OUTPUT_DIR, f"{fname}.png"]), bbox_inches="tight")
  1299. # save_figure(OUTPUT_DIR, "long-anes-panel-e-exemplar_ticks", fig, formats=["png"])
  1300. # %% [markdown]
  1301. # ## Figure 3b
  1302. # %% [markdown]
  1303. # (notebook: 2023-long-anes-measurements.ipynb)
  1304. # %% [markdown]
  1305. # Zoom in of movement raster plot from (a) to show the early moments of movement recovery following anesthesia.
  1306. # %%
  1307. fig, axs = plt.subplots(1, len(mice), figsize=(2 * len(mice), 2))
  1308. wakeup_window = {
  1309. "B15": (48, 52),
  1310. "B20": (62, 66),
  1311. "B26": (101, 105),
  1312. "B6": (48, 52),
  1313. "B8": (48, 52)
  1314. }
  1315. for mouse, ax in zip(filter(lambda x: x != "B33", mice), axs):
  1316. sub_df = ts_df.query('mouse == @mouse')[cols].copy()
  1317. sub_df = sub_df[column_order]
  1318. sub_df.set_index(time_col, inplace=True)
  1319. sub_df = sub_df.iloc[::PLOT_SAMPLE, :]
  1320. for i, column in enumerate(plot_meas):
  1321. meas_name = column.split("/")[-1]
  1322. baseline = jitter_df.query("mouse == @mouse & "
  1323. "measurement_name == @meas_name")["velocities"]
  1324. baseline = baseline.values[0] / 100 # convert mm / s -> mm / frame
  1325. thresh = np.percentile(baseline, 99.9)
  1326. # thresh = np.std(baseline)
  1327. print(mouse, meas_name, "threshold =", thresh)
  1328. ticks = sub_df.index[sub_df[column] > thresh]
  1329. # Plot vertical lines for threshold crossing events
  1330. if len(ticks) > 0:
  1331. ax.vlines(ticks, i, i - 1,
  1332. colors=MEASUREMENT_CMAP[meas_name],
  1333. alpha=0.1,
  1334. linewidths=0.5)
  1335. ax.set_xlim(*wakeup_window[mouse])
  1336. ax.set_title(mouse)
  1337. ax.tick_params(axis="y", left=False)
  1338. ax.set_yticklabels([])
  1339. ax.get_xaxis().set_ticks_position('bottom')
  1340. sns.despine(fig, left=True, top=True, right=True)
  1341. fig.tight_layout()
  1342. fname = f"3b-long-anes-tick-rasters-allmice-zoom"
  1343. # fig.savefig(os.sep.join([OUTPUT_DIR, f"{fname}.png"]), bbox_inches="tight")
  1344. # save_figure(OUTPUT_DIR, "long-anes-panel-f-exemplar_ticks_zoom", fig, formats=["png"])

fig3-part1-cheese3d-general-anesthesia-eeg.ipynb at commit 07ef417, under MIT · at the source

Overview

Authors: Kyle Daruwalla1, Irene Nozal Martin1,2, Linghua Zhang1, Diana Naglič1, Andrew Frankel1, Catherine Rasgaitis1, Rubin Zhao1, Xinyan Zhang1, Zainab Ahmad1, Jeremy C Borniger1, Xun Helen Hou1
  1. Cold Spring Harbor Laboratory, Cold Spring Harbor, NY USA
  2. Dept. of Neuroscience, Stony Brook University, Stony Brook, NY USA
Institutions: Cold Spring Harbor Laboratory (United States); Stony Brook University (United States)
Journal: Nature neuroscience, volume 29, issue 6, pages 1510-1521
Dates: received 3 February 2025; accepted 6 March 2026; published online 27 April 2026; in print 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.1038/s41593-026-02262-8 · PMID 42045464 · PMCID PMC13246446 · OpenAlex W7155908500
Open access: hybrid, a free copy (OpenAlex)
Status: code verified
Categories: mouse (organism), systems (subfield)
Methods: Preprocessing, Statistics, Machine learning, Spectral & time-frequency, fMRI & imaging, Single-unit activity, calcium imaging, Smoothing, state filtering, decompositions, Physiology & signal measures, Connectivity
Keywords: Behavioural methods, Image processing, Hardware and infrastructure, Motor control, Neurophysiology
MeSH: Face*, Facial Expression*, Imaging, Three-Dimensional*, Animals, Male, Mice, Mice, Inbred C57BL, Motion Capture, Movement (* major topic)
Topic: Face Recognition and Perception (Cognitive Neuroscience, Neuroscience), according to OpenAlex
Funding: Fulbright Association; Brain and Behavior Research Foundation (Young investigator Award #29769); NIBIB NIH HHS (T32 EB003383); Schmidt Foundation, CSHL NeuroAI Scholarship
Citations: cited by 4 papers (Europe PMC); 63 references in the paper

Abstract

Facial expressions and movements, from a subtle and ephemeral grimace to vigorous and rapid chewing, offer direct insights into the moment-to-moment changes of neural and physiological processes. Mice, with discernible facial responses and evolutionarily conserved mammalian facial movement control circuits, provide an ideal model in which to unravel the link between facial movement and underlying states. However, existing frameworks lack the spatial or temporal resolution to sensitively track all movements of the mouse face because of its small and conical form factor. We introduce Cheese3D, a computer vision system that captures high-speed 3D motion of the entire mouse face (including ears, eyes, whisker pad and jaw, covering both sides of the face), using a calibrated six-camera array. The interpretable framework extracts dynamics of anatomically meaningful 3D facial features in absolute world units at sub-mm precision. The precise face-wide motion data generated by Cheese3D provides clear insights, as shown by proof-of-principle experiments predicting anesthetic depth from changing facial patterns, inferring tooth and muscle anatomy from fast ingestion motions across the entire face, measuring minute differences in movements evoked by brainstem stimulation and relating neural activity to spontaneous facial movements, including expressive features only measurable in 3D (for example, angles of ear motion). Cheese3D can serve as a discovery tool that renders subtle mouse facial movements as a highly interpretable readout of otherwise hidden processes.

Reproduced under the paper's license (CC BY), from the paper cited above.

Repositories

Its files are read in the Code ↔ Paper reader above, with 19 matches between paragraphs and lines of code.

Hou-Lab-CSHL/cheese3d

License: MIT
State: the link answers, verified on 30 September 2026
Evidence: files inventoried
Commit: 07ef417d37166ad1a272767340827ebe11bd1fe2, 7 July 2026
Languages: Python (27), Jupyter (14), Shell (1)
Size: 121 files, 42 scripts
Software Heritage: not archived
Found in: “Code availability”
Holds: README, license file, environment (pixi.lock, pixi.toml, public-env/pixi.lock, public-env/pixi.toml, packages/cheese3d-annotator/pyproject.toml, packages/cheese3d/pyproject.toml), tests, continuous integration, documentation, 14 notebooks
Not found: CITATION.cff
Tools: NumPy (20 files), pandas (16 files), Matplotlib (10 files), seaborn (7 files), SciPy (6 files), napari (5 files), OpenCV (5 files), scikit-learn (3 files), DeepLabCut (1 file), h5py (1 file), imageio (1 file), JAX (1 file), Open Ephys analysis tools (1 file), scikit-image (1 file)
Availability: 1 check, the latest on 30 September 2026: the link answers
  • 30 September 2026: the link answers
35 files

Zenodo 18573618

License: MIT
State: the link answers, verified on 30 September 2026
Evidence: files inventoried
Languages: Jupyter (14)
Size: 15 files, 14 scripts
Software Heritage: not checked
Found in: “Code availability”
Holds: README, 14 notebooks
Not found: license file, CITATION.cff, environment file, tests, continuous integration, documentation
Tools: Matplotlib (10 files), NumPy (10 files), pandas (10 files), seaborn (10 files), SciPy (6 files), scikit-learn (4 files), h5py (2 files), JAX (1 file), Kilosort (1 file), OpenCV (1 file)
Availability: 1 check, the latest on 30 September 2026: the link answers (HTTP 200)
  • 30 September 2026: the link answers (HTTP 200)
11 files
At the source:

Code availability

Cheese3D software and example datasets are available at https://github.com/Hou-Lab-CSHL/cheese3d. Additionally, a copy of the analysis code is available on Zenodo at 10.5281/zenodo.18573618 (ref. 63).

Reproduced under the paper's license (CC BY), from the paper cited above.

Tracing map

Proposed by the machine: these links were found in the paper and verified at the source, without human review. The map will receive a Zenodo DOI once one of the paper's authors has validated it with their ORCID.

What the map holds:

  • 2 repositories of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 43 scripts, each with its path and the digest of its content;
  • 19 matches between paragraphs of the paper and lines of the code (method lexical-v1);
  • neither the text of the paper nor the code itself.

Its JSON (tracing-map.json) is deposited on Zenodo with its DOI once the map is validated.

Data

Datasets cited

Data availability

All raw data used for analyses in the paper are publicly available on Zenodo at 10.5281/zenodo.18508087 (ref. 62). Source data are provided with this paper.

Reproduced under the paper's license (CC BY), from the paper cited above.

Versions

The history of this record: each version stored by the harvester or made by a correction of its authors or of the maintainers of its code, and what changed in its facts. The texts of the paper (its abstract, its availability statements) are not part of it; versions that changed only those are not listed.

Version 1, 30 September 2026: the first record

Recorded: type, language, journal, volume, issue, pages, dates, 11 authors, 5 keywords, 9 MeSH terms, 4 funders, 53 references.

Cite

This paper

Daruwalla, K., Nozal Martin, I., Zhang, L., Naglič, D., Frankel, A., Rasgaitis, C., Zhao, R., Zhang, X., Ahmad, Z., Borniger, J. C., & Hou, X. H. (2026). Cheese3D enables sensitive detection and analysis of whole-face movement in mice. Nature neuroscience, 29(6), 1510-1521. https://doi.org/10.1038/s41593-026-02262-8

BibTeX

@article{daruwalla2026cheese3d,
author = {Daruwalla, Kyle and Nozal Martin, Irene and Zhang, Linghua and Naglič, Diana and Frankel, Andrew and Rasgaitis, Catherine and Zhao, Rubin and Zhang, Xinyan and Ahmad, Zainab and Borniger, Jeremy C and Hou, Xun Helen},
title = {{Cheese3D enables sensitive detection and analysis of whole-face movement in mice}},
journal = {Nature neuroscience},
year = {2026},
month = apr,
volume = {29},
number = {6},
pages = {1510--1521},
publisher = {Nature Portfolio},
issn = {1097-6256},
doi = {10.1038/s41593-026-02262-8},
url = {https://doi.org/10.1038/s41593-026-02262-8},
pmid = {42045464},
pmcid = {PMC13246446}
}

RIS

TY - JOUR
AU - Daruwalla, Kyle
AU - Nozal Martin, Irene
AU - Zhang, Linghua
AU - Naglič, Diana
AU - Frankel, Andrew
AU - Rasgaitis, Catherine
AU - Zhao, Rubin
AU - Zhang, Xinyan
AU - Ahmad, Zainab
AU - Borniger, Jeremy C
AU - Hou, Xun Helen
TI - Cheese3D enables sensitive detection and analysis of whole-face movement in mice
T2 - Nature neuroscience
J2 - Nat Neurosci
PY - 2026
DA - 2026/04/27
VL - 29
IS - 6
SP - 1510
EP - 1521
SN - 1097-6256
PB - Nature Portfolio
DO - 10.1038/s41593-026-02262-8
UR - https://doi.org/10.1038/s41593-026-02262-8
LA - en
ER -

CSL-JSON

{
"id": "10.1038/s41593-026-02262-8",
"type": "article-journal",
"title": "Cheese3D enables sensitive detection and analysis of whole-face movement in mice",
"container-title": "Nature neuroscience",
"author": [
{
"family": "Daruwalla",
"given": "Kyle"
},
{
"family": "Nozal Martin",
"given": "Irene"
},
{
"family": "Zhang",
"given": "Linghua"
},
{
"family": "Naglič",
"given": "Diana"
},
{
"family": "Frankel",
"given": "Andrew"
},
{
"family": "Rasgaitis",
"given": "Catherine"
},
{
"family": "Zhao",
"given": "Rubin"
},
{
"family": "Zhang",
"given": "Xinyan"
},
{
"family": "Ahmad",
"given": "Zainab"
},
{
"family": "Borniger",
"given": "Jeremy C"
},
{
"family": "Hou",
"given": "Xun Helen"
}
],
"container-title-short": "Nat Neurosci",
"volume": "29",
"issue": "6",
"page": "1510-1521",
"DOI": "10.1038/s41593-026-02262-8",
"PMID": "42045464",
"PMCID": "PMC13246446",
"ISSN": "1097-6256",
"publisher": "Nature Portfolio",
"URL": "https://doi.org/10.1038/s41593-026-02262-8",
"language": "en",
"issued": {
"date-parts": [
[
2026,
4,
27
]
]
}
}

The tracing map gets a citation of its own once an author has validated it and it has a DOI.

Similar papers

The papers with a page that share the most with this one: the tools found in their code, their categories, datasets, cited references and authors, the rarest counting most.

[1] doi:10.1016/j.celrep.2026.117420 [code]
Neural population dynamics of direct electrical stimulation of neocortex.
Journal: Cell reports
In common: Open Ephys analysis tools, napari, JAX, 9 other tools, systems, mouse
[2] doi:10.1038/s41593-026-02232-0 [code]
Entorhinal cortex represents task-relevant remote locations independently of CA1.
Journal: Nature neuroscience
In common: DeepLabCut, Kilosort, OpenCV, 8 other tools, systems, mouse, 2 references
[3] doi:10.7554/elife.109717 [code]
Retrosplenial cortex enables context-dependent goal-directed sensorimotor transformation.
Journal: eLife
In common: Kilosort, imageio, OpenCV, 8 other tools, systems, mouse, 1 reference
[4] doi: [code]
Real-time closed-loop feedback system for mouse mesoscale cortical signal and movement control
Journal: eLife
In common: napari, imageio, OpenCV, 8 other tools, mouse, 1 reference
[5] doi:10.1038/s41467-026-72152-x [code]
Centralized brain networks controlling antennal grooming coordination.
Journal: Nature communications
In common: DeepLabCut, OpenCV, h5py, 6 other tools, systems, 2 references
[6] doi:10.1038/s41586-026-10679-1 [code]
Cortical development dynamics across autism spectrum disorder mouse models.
Journal: Nature
In common: imageio, OpenCV, scikit-image, 7 other tools, mouse, 1 reference
[7] doi:10.1038/s41467-026-72057-9 [code]
Sex-specific behavioral feedback modulates sensorimotor processing and drives flexible social behavior.
Journal: Nature communications
In common: OpenCV, scikit-image, h5py, 6 other tools, systems, 2 references
[8] doi:10.1016/j.crmeth.2026.101421 [code]
EthoPy provides an accessible platform for reproducible behavioral neuroscience.
Journal: Cell reports methods
In common: imageio, OpenCV, h5py, 6 other tools, mouse, 2 references
[9] doi:10.3389/fendo.2026.1828487 [code]
Castration-induced nigrostriatal deficits are linked to reduced TrkB and loss of mature spines in the dorsal striatum.
Journal: Frontiers in endocrinology
In common: imageio, OpenCV, scikit-image, 7 other tools, mouse, 1 reference
[10] doi:10.1371/journal.pcbi.1014571 [code]
SynAPSeg: A novel dataset and image analysis framework for deep learning-based synapse detection and quantification.
Journal: PLoS computational biology
In common: napari, imageio, OpenCV, 7 other tools

Contribute

The authors of this paper can claim it, correct its record and validate its tracing map, and the maintainers of its code (its owner, or a public member of its organization) correct what it says of their repository; anyone signed in can ask for its removal. Every request goes to OSCR's own machine, which answers it; your account page follows them.

Sign in with ORCID to claim this paper as one of its authors, correct its record or validate its tracing map: when the paper's metadata lists your ORCID iD, you are recognized at once. Maintainers of its code: sign in with GitHub, then claim the repository on your account page.

Request its removal

To ask OSCR to remove this record, the copies of its authors' scripts or its tracing map, use the removal request page: signed in, you say who you are, what to remove and why, then review and confirm the request. Published rules decide every request (how).

Discussion, reproductions, activity

Discussion: questions and error reports about this paper and its code, from signed-in readers and its authors. It opens with sign-in.

Reproductions: reports from readers who ran the authors' code: what they reproduced, with which environment, commit and data. It opens with sign-in.

Activity: what happens around this paper: new versions of its record, its map's validation, discussions and reproductions. It opens with sign-in.