OSCR

Fear conditioning biases olfactory sensory neuron frequencies across generations.

Code ↔ Paper

The paper beside its authors' code: matches between them have not been computed for this paper yet.

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 · 700 lines · 24 KB · MIT

  1. # %%
  2. # import necessary packages
  3. import os
  4. import numpy as np
  5. import pandas as pd
  6. import seaborn as sns
  7. import matplotlib.pyplot as plt
  8. from scipy import stats
  9. from statsmodels.stats.multitest import multipletests
  10. from itertools import combinations
  11. from matplotlib import rc
  12. rc('font',**{'family':'sans-serif','sans-serif':['Arial']})
  13. import warnings
  14. warnings.filterwarnings('ignore')
  15. # %%
  16. # specify pathnames of directories and moseq_df/stats_df csvs generated by kpMoSeq
  17. project_dir= '' # the full path to the project directory
  18. model_name='' # name of model to analyze (e.g. something like `2023_05_23-15_19_03`)
  19. fig_dir = '' # name of folder you want figures to be saved in
  20. stats_dir = project_dir+model_name+'/stats/' # where you want stats files to be saved e.g. significant syllables, p-vals of pairwise comparisons
  21. moseq_df_filename = 'moseq_df.csv' # csv filename of saved moseq_df
  22. stats_df_filename = 'p_up_p_stats_df.csv' # csv filename of saved stats_df
  23. sig_syllables_filename = 'p_up_p_sig_syllables_freq.csv' # csv filename of saved sig_syllables df, if saved -- if not, can generate below
  24. moseq_df = pd.read_csv(project_dir+model_name+'/moseq_df/'+moseq_df_filename) # read in moseq_df
  25. stats_df = pd.read_csv(project_dir+model_name+'/stats_df/'+stats_df_filename) # read in stats_df
  26. # %%
  27. # define groups for comparison (group_1, group_2), statistic of interest (frequency or duration), and p-val
  28. group_1 = 'f1_up'
  29. group_2 = 'f1_p'
  30. stat = 'frequency'
  31. thresh = 0.05
  32. # %%
  33. # define functions needed for analysis (from kpMoSeq code)
  34. def run_kruskal(
  35. stats_df,
  36. statistic="frequency",
  37. n_perm=10000,
  38. seed=42,
  39. thresh=0.05,
  40. mc_method="fdr_bh",
  41. ):
  42. """Run Kruskal-Wallis test on syllable usage data.
  43. Parameters
  44. ----------
  45. stats_df : pandas.DataFrame
  46. DataFrame containing syllable usage data.
  47. statistic : str, optional
  48. Statistic to use for KW test, by default 'frequency'
  49. n_perm : int, optional
  50. Number of permutations to run, by default 10000
  51. seed : int, optional
  52. Random seed, by default 42
  53. thresh : float, optional
  54. Alpha threshold to consider syllable significant, by default 0.05
  55. mc_method : str, optional
  56. Multiple Corrections method to use, by default "fdr_bh"
  57. Returns
  58. -------
  59. df_k_real : pandas.DataFrame
  60. DataFrame containing KW test results.
  61. df_pval_corrected : pandas.DataFrame
  62. DataFrame containing Dunn's test results with corrected p-values.
  63. significant_syllables : list
  64. List of corrected KW significant syllables (syllables with p-values < thresh).
  65. """
  66. rnd = np.random.RandomState(seed=seed)
  67. # get grouped mean data
  68. grouped_data = (
  69. stats_df.pivot_table(
  70. index=["group", "name"], columns="syllable", values=statistic
  71. )
  72. .replace(np.nan, 0)
  73. .reset_index()
  74. )
  75. # compute KW constants
  76. vc = grouped_data.group.value_counts().loc[grouped_data.group.unique()]
  77. n_per_group = vc.values
  78. group_names = vc.index
  79. cum_group_idx = np.insert(np.cumsum(n_per_group), 0, 0)
  80. num_groups = len(group_names)
  81. # get all syllable usage data
  82. df_only_stats = grouped_data.drop(["group", "name"], axis=1)
  83. syllable_data = grouped_data.drop(["group", "name"], axis=1).values
  84. N_m, N_s = syllable_data.shape
  85. # Run KW and return H-stats
  86. h_all, real_ranks, X_ties = run_manual_KW_test(
  87. df_usage=df_only_stats,
  88. merged_usages_all=syllable_data,
  89. num_groups=num_groups,
  90. n_per_group=n_per_group,
  91. cum_group_idx=cum_group_idx,
  92. n_perm=n_perm,
  93. seed=seed,
  94. )
  95. # find the real k_real
  96. df_k_real = pd.DataFrame(
  97. [
  98. stats.kruskal(
  99. *np.array_split(syllable_data[:, s_i], np.cumsum(n_per_group[:-1]))
  100. )
  101. for s_i in range(N_s)
  102. ]
  103. )
  104. # multiple test correction
  105. df_k_real["p_adj"] = multipletests(
  106. ((h_all > df_k_real.statistic.values).sum(0) + 1) / n_perm,
  107. alpha=thresh,
  108. method=mc_method,
  109. )[1]
  110. # return significant syllables based on the threshold
  111. df_k_real["is_sig"] = df_k_real["p_adj"] <= thresh
  112. # Run Dunn's z-test statistics
  113. (
  114. null_zs_within_group,
  115. real_zs_within_group,
  116. ) = dunns_z_test_permute_within_group_pairs(
  117. grouped_data, vc, real_ranks, X_ties, N_m, group_names, rnd, n_perm
  118. )
  119. # Compute p-values from Dunn's z-score statistics
  120. df_pair_corrected_pvalues, _ = compute_pvalues_for_group_pairs(
  121. real_zs_within_group,
  122. null_zs_within_group,
  123. df_k_real,
  124. group_names,
  125. n_perm,
  126. thresh,
  127. mc_method,
  128. )
  129. # combine Dunn's test results into single DataFrame
  130. df_z = pd.DataFrame(real_zs_within_group)
  131. df_z.index = df_z.index.set_names("syllable")
  132. dunn_results_df = df_z.reset_index().melt(id_vars=[("syllable", "")])
  133. dunn_results_df.rename(
  134. columns={"variable_0": "group1", "variable_1": "group2"}, inplace=True
  135. )
  136. # Get intersecting significant syllables between
  137. intersect_sig_syllables = {}
  138. pvals = {}
  139. for pair in df_pair_corrected_pvalues.columns.tolist():
  140. intersect_sig_syllables[pair] = np.where(
  141. (df_pair_corrected_pvalues[pair] < thresh) & (df_k_real.is_sig)
  142. )[0]
  143. pvals[pair] = df_pair_corrected_pvalues[pair]
  144. return df_k_real, dunn_results_df, intersect_sig_syllables, pvals
  145. def run_manual_KW_test(
  146. df_usage,
  147. merged_usages_all,
  148. num_groups,
  149. n_per_group,
  150. cum_group_idx,
  151. n_perm=10000,
  152. seed=42,
  153. ):
  154. """Run a manual Kruskal-Wallis test compare the results agree with the
  155. scipy.stats.kruskal function.
  156. Parameters
  157. ----------
  158. df_usage : pandas.DataFrame
  159. DataFrame with syllable usages. shape = (N_m, n_syllables)
  160. merged_usages_all : np.array
  161. numpy array format of the df_usage DataFrame.
  162. num_groups : int
  163. Number of unique groups
  164. n_per_group : list
  165. list of value counts for recordings per group. len == num_groups.
  166. cum_group_idx : list
  167. list of indices for different groups. len == num_groups + 1.
  168. n_perm : int, optional
  169. Number of permuted samples to generate, by default 10000
  170. seed : int, optional
  171. Random seed used to initialize the pseudo-random number generator, by default 42
  172. Returns
  173. -------
  174. h_all : np.array
  175. Array of H-stats computed for given n_syllables; shape = (n_perms, N_s)
  176. real_ranks : np.array
  177. Array of syllable ranks, shape = (N_m, n_syllables)
  178. X_ties : np.array
  179. 1-D list of tied ranks, where if value > 0, then rank is tied. len(X_ties) = n_syllables
  180. """
  181. N_m, N_s = merged_usages_all.shape
  182. # create random index array n_perm times
  183. rnd = np.random.RandomState(seed=seed)
  184. perm = rnd.rand(n_perm, N_m).argsort(-1)
  185. # get degrees of freedom
  186. dof = num_groups - 1
  187. real_ranks = np.apply_along_axis(stats.rankdata, 0, merged_usages_all)
  188. X_ties = df_usage.apply(get_tie_correction, 0, N_m=N_m).values
  189. KW_tie_correct = np.apply_along_axis(stats.tiecorrect, 0, real_ranks)
  190. # rank data
  191. perm_ranks = real_ranks[perm]
  192. # get square of sums for each group
  193. ssbn = np.zeros((n_perm, N_s))
  194. for i in range(num_groups):
  195. ssbn += (
  196. perm_ranks[:, cum_group_idx[i] : cum_group_idx[i + 1]].sum(1) ** 2
  197. / n_per_group[i]
  198. )
  199. # h-statistic
  200. h_all = 12.0 / (N_m * (N_m + 1)) * ssbn - 3 * (N_m + 1)
  201. h_all /= KW_tie_correct
  202. p_vals = stats.chi2.sf(h_all, df=dof)
  203. # check that results agree
  204. p_i = np.random.randint(n_perm)
  205. s_i = np.random.randint(N_s)
  206. kr = stats.kruskal(
  207. *np.array_split(
  208. merged_usages_all[perm[p_i, :], s_i], np.cumsum(n_per_group[:-1])
  209. )
  210. )
  211. assert (kr.statistic == h_all[p_i, s_i]) & (
  212. kr.pvalue == p_vals[p_i, s_i]
  213. ), "manual KW is incorrect"
  214. return h_all, real_ranks, X_ties
  215. def get_tie_correction(x, N_m):
  216. """Assign tied rank values to the average of the ranks they would have
  217. received if they had not been tied for Kruskal-Wallis helper function.
  218. Parameters
  219. ----------
  220. x : pd.Series
  221. syllable usages for a single recording.
  222. N_m : int
  223. Number of total recordings.
  224. Returns
  225. -------
  226. corrected_rank : float
  227. average of the inputted tied ranks.
  228. """
  229. vc = x.value_counts()
  230. tie_sum = 0
  231. if (vc > 1).any():
  232. tie_sum += np.sum(vc[vc != 1] ** 3 - vc[vc != 1])
  233. return tie_sum / (12.0 * (N_m - 1))
  234. def dunns_z_test_permute_within_group_pairs(
  235. df_usage, vc, real_ranks, X_ties, N_m, group_names, rnd, n_perm
  236. ):
  237. """Run Dunn's z-test statistic on combinations of all group pairs, handling
  238. pre- computed tied ranks.
  239. Parameters
  240. ----------
  241. df_usage : pandas.DataFrame
  242. DataFrame containing only pre-computed syllable stats.
  243. vc : pd.Series
  244. value counts of recordings in each group.
  245. real_ranks : np.array
  246. Array of syllable ranks.
  247. X_ties : np.array
  248. 1-D list of tied ranks, where if value > 0, then rank is tied
  249. N_m : int
  250. Number of recordings.
  251. group_names : pd.Index
  252. Index list of unique group names.
  253. rnd : np.random.RandomState
  254. Pseudo-random number generator.
  255. n_perm : int
  256. Number of permuted samples to generate.
  257. Returns
  258. -------
  259. null_zs_within_group : dict
  260. dict of group pair keys paired with vector of Dunn's z-test statistics of the null hypothesis.
  261. real_zs_within_group : dict
  262. dict of group pair keys paired with vector of Dunn's z-test statistics
  263. """
  264. null_zs_within_group = {}
  265. real_zs_within_group = {}
  266. A = N_m * (N_m + 1.0) / 12.0
  267. for i_n, j_n in combinations(group_names, 2):
  268. is_i = df_usage.group == i_n
  269. is_j = df_usage.group == j_n
  270. n_mice = is_i.sum() + is_j.sum()
  271. ranks_perm = real_ranks[(is_i | is_j)][rnd.rand(n_perm, n_mice).argsort(-1)]
  272. diff = np.abs(
  273. ranks_perm[:, : is_i.sum(), :].mean(1)
  274. - ranks_perm[:, is_i.sum() :, :].mean(1)
  275. )
  276. B = 1.0 / vc.loc[i_n] + 1.0 / vc.loc[j_n]
  277. # also do for real data
  278. group_ranks = real_ranks[(is_i | is_j)]
  279. real_diff = np.abs(
  280. group_ranks[: is_i.sum(), :].mean(0) - group_ranks[is_i.sum() :, :].mean(0)
  281. )
  282. # add to dict
  283. pair = (i_n, j_n)
  284. null_zs_within_group[pair] = diff / np.sqrt((A - X_ties) * B)
  285. real_zs_within_group[pair] = real_diff / np.sqrt((A - X_ties) * B)
  286. return null_zs_within_group, real_zs_within_group
  287. def compute_pvalues_for_group_pairs(
  288. real_zs_within_group,
  289. null_zs,
  290. df_k_real,
  291. group_names,
  292. n_perm=10000,
  293. thresh=0.05,
  294. mc_method="fdr_bh",
  295. ):
  296. """Adjust the p-values from Dunn's z-test statistics and computes the
  297. resulting significant syllables with the adjusted p-values.
  298. Parameters
  299. ----------
  300. real_zs_within_group : dict
  301. dict of group pair keys paired with vector of Dunn's z-test statistics
  302. null_zs : dict
  303. dict of group pair keys paired with vector of Dunn's z-test statistics of the null hypothesis.
  304. df_k_real : pandas.DataFrame
  305. DataFrame of KW test results.
  306. group_names : pd.Index
  307. Index list of unique group names.
  308. n_perm : int, optional
  309. Number of permuted samples to generate, by default 10000
  310. thresh : float, optional
  311. Alpha threshold to consider syllable significant, by default 0.05
  312. mc_method : str, optional
  313. Multiple Corrections method to use, by default "fdr_bh"
  314. verbose : bool, optional
  315. indicates whether to print out the significant syllable results, by default False
  316. Returns
  317. -------
  318. df_pval_corrected : pandas.DataFrame
  319. DataFrame containing Dunn's test results with corrected p-values.
  320. significant_syllables : list
  321. List of corrected KW significant syllables (syllables with p-values < thresh).
  322. """
  323. # do empirical p-val calculation for all group permutation
  324. p_vals_allperm = {}
  325. for pair in combinations(group_names, 2):
  326. p_vals_allperm[pair] = (
  327. (null_zs[pair] > real_zs_within_group[pair]).sum(0) + 1
  328. ) / n_perm
  329. # summarize into df
  330. df_pval = pd.DataFrame(p_vals_allperm)
  331. def correct_p(x):
  332. return multipletests(x, alpha=thresh, method=mc_method)[1]
  333. df_pval_corrected = df_pval.apply(correct_p, axis=1, result_type="broadcast")
  334. return df_pval_corrected, ((df_pval_corrected[df_k_real.is_sig] < thresh).sum(0))
  335. def sort_syllables_by_stat(stats_df, stat="frequency"):
  336. """Sort sylllabes by the stat and return the ordering and label mapping.
  337. Parameters
  338. ----------
  339. stats_df : pandas.DataFrame
  340. the stats dataframe that contains kinematic data and the syllable label for each recording and each syllable
  341. stat : str, optional
  342. the statistic to sort on, by default 'frequency'
  343. Returns
  344. -------
  345. ordering : list
  346. the list of syllables sorted by the stat
  347. relabel_mapping : dict
  348. the mapping from the syllable to the new plotting label
  349. """
  350. # stats_df frequency normalized by session
  351. # mean frequency by syllable don't always refect the ordering from reindexing
  352. # use the syllable label as ordering instead
  353. if stat == "frequency":
  354. ordering = sorted(stats_df.syllable.unique())
  355. else:
  356. ordering = (
  357. stats_df.drop(
  358. [col for col, dtype in stats_df.dtypes.items() if dtype == "object"],
  359. axis=1,
  360. )
  361. .groupby("syllable")
  362. .mean()
  363. .sort_values(by=stat, ascending=False)
  364. .index
  365. )
  366. # Get sorted ordering
  367. ordering = list(ordering)
  368. # Get order mapping
  369. relabel_mapping = {o: i for i, o in enumerate(ordering)}
  370. return ordering, relabel_mapping
  371. # %%
  372. # read in sig_syllables if previously saved into csv
  373. # sig_syllables = []
  374. # all_sig_syllables = pd.read_csv(project_dir+model_name+'/'+sig_syllables_filename)
  375. # for i in range(all_sig_syllables.shape[0]):
  376. # if all_sig_syllables.iloc[i,0] == group_1 and all_sig_syllables.iloc[i,1] == group_2:
  377. # sig_syllables = all_sig_syllables.iloc[i,2]
  378. # elif all_sig_syllables.iloc[i,0] == group_2 and all_sig_syllables.iloc[i,1] == group_1:
  379. # sig_syllables = all_sig_syllables.iloc[i,2]
  380. # sig_syllables
  381. # %%
  382. # extract significant pairs and p-values from stats_df
  383. _, _, sig_pairs, pvals = run_kruskal(stats_df)
  384. # %%
  385. # save pairwise p-vals in csv
  386. import csv
  387. with open(os.path.join(stats_dir,'pairwise_pvals.csv'), 'w') as csvfile:
  388. writer = csv.writer(csvfile)
  389. for item in pvals:
  390. first_group = item[0]
  391. second_group = item[1]
  392. pval = (pvals[item])
  393. writer.writerow([first_group,second_group,pval])
  394. # %%
  395. # specify the two groups for pairwise comparison and plot relative frequencies of syllables, marking significant syllables with a red star
  396. # right over the x axis
  397. current_stats_df = stats_df[(stats_df['group'] == group_1) | (stats_df['group'] == group_2)]
  398. ordering, _ = sort_syllables_by_stat(stats_df, stat=stat)
  399. if (group_1, group_2) in sig_pairs.keys():
  400. sig_sylls = sig_pairs.get((group_1, group_2))
  401. else:
  402. sig_sylls = sig_pairs.get((group_2, group_1))
  403. hue = 'group'
  404. fig, ax = plt.subplots(1,1,figsize=(6,4))
  405. ax = sns.pointplot(
  406. data=current_stats_df,
  407. x="syllable",
  408. y=stat,
  409. hue=hue,
  410. palette='Reds',
  411. linestyle="none",
  412. errorbar=('se'),
  413. markersize=3,
  414. marker="o",
  415. err_kws={'linewidth':1}
  416. )
  417. syllables = stats_df["syllable"].unique()
  418. markings = []
  419. for s in sig_sylls:
  420. if s in ordering:
  421. markings.append(np.where(ordering == s)[0])
  422. else:
  423. continue
  424. if len(markings) > 0:
  425. markings = np.concatenate(markings)
  426. plt.scatter(markings, [0] * len(markings), color="r", marker="*",linewidths=0.05)
  427. else:
  428. print("No significant syllables found.")
  429. ax.spines["top"].set_visible(False)
  430. ax.spines["right"].set_visible(False)
  431. ax.spines["left"].set_linewidth(0.5)
  432. ax.spines["bottom"].set_linewidth(0.5)
  433. ax.legend(frameon=False, loc='upper right')
  434. ax.set_ylim([-0.003,None])
  435. plt.tight_layout()
  436. # save fig as png and eps in figs directory
  437. fig.savefig(project_dir+model_name+'/'+fig_dir+'p_'+group_1+'_'+group_2+'_syllable_freq.png')
  438. fig.savefig(project_dir+model_name+'/'+fig_dir+'p_'+group_1+'_'+group_2+'_syllable_freq.eps', format='eps')
  439. # %%
  440. # plot correlation graphs for a phenotype of interest, here avoidance index (ai)
  441. # ai data was saved in an xlsx file with the columns 'file' (subject) and 'ai'
  442. for s in range(len(stats_df["syllable"].unique())):
  443. current_syllable = s
  444. current_stats_df = stats_df[(stats_df['syllable'] == current_syllable)]
  445. correlation_df = pd.DataFrame(columns=['group','frequency','ai'])
  446. # insert name of excel file with phenotype data (2 columns, indicate column names in usecols=[])
  447. ai_df = pd.read_excel('',usecols=['file','ai'])
  448. for i in range(current_stats_df.shape[0]):
  449. new_entry_df = pd.DataFrame(columns=['group','frequency','ai'],index=range(1))
  450. group = current_stats_df.iloc[i,0]
  451. name = current_stats_df.iloc[i,1]
  452. frequency = current_stats_df.iloc[i,15]
  453. # find ai for current subject in ai_df
  454. for j in range(ai_df.shape[0]):
  455. if name == ai_df.iloc[j,0]:
  456. ai = ai_df.iloc[j,1]
  457. new_entry_df.iloc[0,0] = group
  458. new_entry_df.iloc[0,1] = float(frequency)
  459. new_entry_df.iloc[0,2] = float(ai)
  460. correlation_df = pd.concat([correlation_df,new_entry_df])
  461. correlation_df = correlation_df.astype({'frequency':'float','ai':'float'})
  462. color = sns.color_palette(palette='BuPu')[5]
  463. # creates correlation plots colored by group
  464. lm = sns.lmplot(data=correlation_df,x='frequency',y='ai',col="group",facet_kws=dict(sharex=False, sharey=False),scatter_kws={"color":color}, line_kws={"color":color})
  465. font={'fontname':'Arial'}
  466. for group, ax in lm.axes_dict.items():
  467. r, p = stats.pearsonr(correlation_df[correlation_df['group'] == group]['frequency'],correlation_df[correlation_df['group'] == group]['ai'])
  468. r2=r**2
  469. ax.text(0.05,1,'$r^2$={:.2f}, p={:.2g}'.format(r2,p),horizontalalignment='left',verticalalignment='top',transform=ax.transAxes)
  470. ax.spines["top"].set_visible(False)
  471. ax.spines["right"].set_visible(False)
  472. ax.spines["left"].set_linewidth(0.5)
  473. ax.spines["bottom"].set_linewidth(0.5)
  474. ax.set_ylim([-1,1])
  475. plt.suptitle('syllable '+str(current_syllable),fontsize=20,y=1.05)
  476. lm.savefig(project_dir+model_name+'/'+fig_dir+'syllable '+str(current_syllable)+'_corr_by_group.png')
  477. for group, ax in lm.axes_dict.items():
  478. plt.clf()
  479. plt.close()
  480. # creates correlation plots of all combined data
  481. rplot = sns.regplot(data=correlation_df,x='frequency',y='ai',scatter_kws={"color":color}, line_kws={"color":color})
  482. ax = rplot.axes
  483. r, p = stats.pearsonr(correlation_df['frequency'],correlation_df['ai'])
  484. r2=r**2
  485. ax.text(0.05,1,'$r^2$={:.2f}, p={:.2g}'.format(r2,p),horizontalalignment='left',verticalalignment='top',transform=ax.transAxes)
  486. ax.spines["top"].set_visible(False)
  487. ax.spines["right"].set_visible(False)
  488. ax.spines["left"].set_linewidth(0.5)
  489. ax.spines["bottom"].set_linewidth(0.5)
  490. ax.set_ylim([-1,1])
  491. plt.title('syllable '+str(current_syllable),fontsize=16,y=1.05)
  492. plt.tight_layout()
  493. figure = rplot.get_figure()
  494. figure.savefig(project_dir+model_name+'/'+fig_dir+'syllable '+str(current_syllable)+'_corr_combined.png')
  495. plt.clf()
  496. plt.close()
  497. # %%
  498. sns.color_palette(palette='BuPu')
  499. # %%
  500. # format of moseq_df
  501. moseq_df
  502. # %%
  503. # for analysis with respect to the subject's position in the video, can create a csv indicating boundaries
  504. # here, x positions are defined for each video to determine whether the mouse is in the left chamber or right chamber
  505. # if arena does not move between videos, can be manually entered
  506. # here, since arena moves between videos, x cutoffs are defined for each video/subject
  507. # read in left and right x cutoff values for each video
  508. l_r_df = pd.read_csv('/Users/claraliff/Desktop/Lab/eLife_paper/kpMoSeq/v3/v3_f0_f1_moseq/2024_11_18-12_14_41/l_r_xcutoffs.csv')
  509. # define whether left or right is conditioned odor side using an experiment metadata excel sheet
  510. c_o_side = pd.read_excel('/Users/claraliff/Desktop/Lab/eLife_paper/kpMoSeq/v3/v3_f0_f1_csvs_grouped.xlsx',usecols="A,D")
  511. port_x_df = pd.DataFrame(columns=['name','port_x'],index=range(99))
  512. port_x_df.loc[:,'name'] = c_o_side['file']
  513. port_x_df
  514. # in our videos, the conditioned odor port, a region of interest in our videos, was always located 200 pixels to the left of the left
  515. # chamber entrance or 200 pixels to the right of the right chamber entrance
  516. # port_x_df defines the x position of the conditioned odor port for each video/subject, to later be used to calculate real-time distance of
  517. # the mouse from the odor source
  518. for x in range(l_r_df.shape[0]):
  519. if c_o_side.iloc[x,1] == "R":
  520. port_x = l_r_df.iloc[x,2] + 200
  521. elif c_o_side.iloc[x,1] == "L":
  522. port_x = l_r_df.iloc[x,1] - 200
  523. port_x_df.iloc[x,1] = port_x
  524. port_x_df
  525. # %%
  526. # append two columns to moseq_df: the x position of the conditioned odor port, and the x distance of the mouse from the odor port
  527. moseq_df_dist = moseq_df.copy()
  528. moseq_df_dist["port_x"] = np.nan
  529. moseq_df_dist["port_dist"] = np.nan
  530. moseq_df_dist
  531. # %%
  532. # for each row (frame) of all videos, calculate the x distance of the mouse x centroid from the x coordinate of the conditioned odor port
  533. for x in range(moseq_df_dist.shape[0]):
  534. current_name = moseq_df_dist.iloc[x,0]
  535. i = port_x_df.index[port_x_df.iloc[:,0].str.contains(current_name)]
  536. port_x = port_x_df.iloc[i,1]
  537. moseq_df_dist.iloc[x,10] = port_x
  538. current_centroid_x = moseq_df_dist.iloc[x,1]
  539. port_dist = abs(port_x - current_centroid_x)
  540. moseq_df_dist.iloc[x,11] = port_dist
  541. moseq_df_dist
  542. # %%
  543. # sanity check that all distances are positive
  544. moseq_df_dist[moseq_df_dist['port_dist']<0]
  545. # %%
  546. # save moseq_df with distance info
  547. moseq_df_dist.to_csv(project_dir+model_name+'/moseq_df/moseq_df_dist.csv')
  548. # %%
  549. # read in moseq_df_dist if continuing after checkpoint
  550. # moseq_df_dist = pd.read_csv(project_dir+model_name+'/moseq_df/moseq_df_dist.csv')
  551. # %%
  552. # create temp_df, a subset of moseq_df_dist with your two groups for comparison
  553. # filtering to only analyze top 20 (20 most frequent) syllables
  554. temp_df = moseq_df_dist[(moseq_df_dist['group'] == 'f1_up') | (moseq_df_dist['group'] == 'f1_p')]
  555. temp_df = temp_df[temp_df['syllable'] < 20]
  556. temp_df
  557. # %%
  558. # create variables for x position (all frames, all subjects) for each group
  559. f1_p_x_dist = temp_df[temp_df['group'] == 'f1_p']
  560. f1_p_x_dist = f1_p_x_dist.iloc[:,2]
  561. f1_p_x_dist
  562. f1_up_x_dist = temp_df[temp_df['group'] == 'f1_up']
  563. f1_up_x_dist = f1_up_x_dist.iloc[:,2]
  564. f1_up_x_dist
  565. # %%
  566. # plot kde for x position by group
  567. # black bars indicate the left/right chamber entries (cutoffs)
  568. d = sns.displot(temp_df,x='port_dist',palette='BuPu',hue='group',kind="kde",common_norm=True)
  569. d.set(xlim=(0,600))
  570. plt.axvline(200,0,1,color='black')
  571. plt.axvline(350,0,1,color='black')
  572. d.savefig(project_dir+model_name+'/'+fig_dir+'port_dist_hist_kde_common_norm.eps', format='eps')
  573. # %%
  574. # plot as histogram
  575. d = sns.displot(temp_df,x='port_dist',palette='BuPu',hue='group',kind="hist",stat='frequency',bins=50)
  576. d.set(xlim=(0,600))
  577. plt.axvline(200,0,1,color='black')
  578. plt.axvline(350,0,1,color='black')
  579. # d.savefig(project_dir+model_name+'/'+fig_dir+'port_dist_hist_freq_bins25.eps', format='eps')
  580. # d.savefig(project_dir+model_name+'/'+fig_dir+'port_dist_hist_freq_bins25.png', format='png')
  581. # %%
  582. # plot syllable usage across relative x position for each syllable
  583. dp = sns.displot(temp_df,x='port_dist',palette='BuPu',col='syllable',hue='group',col_wrap=5,facet_kws={'sharey':False},kind='kde',common_norm=False)
  584. for ax in dp.axes.flat:
  585. ymin,ymax=ax.get_ylim()
  586. ax.set(xlim=(0,600))
  587. ax.vlines(200,ymin,ymax,colors='black')
  588. ax.vlines(350,ymin,ymax,colors='black')
  589. dp.savefig(project_dir+model_name+'/'+fig_dir+'port_dist_hist_by_syll.eps', format='eps')

plot_kpmoseq_data.ipynb at commit cc8935b, under MIT · at the source

Overview

Authors: Clara W Liff1,2, Yasmine R Ayman1, Eliza CB Jaeger1, Avery Cardeiro1, Hudson S Lee1, Alexis Kim1, Angelica Vina-Abarracin1, Dianne-Lee KD Ferguson1,3, Bianca J Marlin1,2,3,4
  1. Mortimer B. Zuckerman Mind Brain and Behavior Institute, Columbia University, New York, United States
  2. Department of Neuroscience, Columbia University, New York, United States
  3. Howard Hughes Medical Institute, Columbia University, New York, United States
  4. Department of Psychology, Columbia University, New York, United States
Journal: eLife, volume 12, article RP92882
Dates: published online 14 April 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.7554/elife.92882 · PMID 41979323 · PMCID PMC13078775 · OpenAlex W4389781873
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: mouse (organism), cellular / molecular (subfield)
Methods: Statistics, Evoked potentials, Machine learning
Keywords: Mouse
MeSH: Conditioning, Psychological*, Fear*, Olfactory Mucosa*, Olfactory Receptor Neurons*, Animals, Female, Male, Mice, Mice, Inbred C57BL, Odorants, Smell (* major topic)
Topic: Olfactory and Sensory Function Studies (Sensory Systems, Neuroscience), according to OpenAlex
Funding: United Negro College Fund (E.E. Just Fellowship CU20-1071); Howard Hughes Medical Institute (Freeman Hrabrowski Scholar); NIH HHS (1S10OD023587-01); Simons Foundation (Simons Society of Fellows Junior Fellowship 524991); NIMH NIH HHS (T32MH126036); Brain and Behavior Research Foundation (NARSAD Young Investigator Grant 30380)
Citations: cited by 3 papers (Europe PMC); 50 references in the paper
Research resources: RRID:AB_10000240, RRID:AB_2535866

Abstract

The main olfactory epithelium initiates the process of odor encoding. Recent studies have demonstrated intergenerationally inherited changes in the olfactory system in response to fear conditioning, resulting in increases in olfactory sensory neuron frequencies and altered responses to odors. We investigated changes in the cellular composition of the olfactory epithelium in response to an aversive stimulus. Here, we achieve volumetric cellular resolution to demonstrate that olfactory fear conditioning increases the number of odor-encoding neurons in mice that experience odor-shock conditioning (F0), as well as their unconditioned offspring (F1). We demonstrate that the increase in F0 is due, in part, to the biasing of the stem cell layer of the main olfactory epithelium. A detailed analysis of F1 behavior revealed subtle odor-specific differences between the offspring of unconditioned and conditioned parents, despite the absence of an active aversion to the conditioned odor. Thus, we reveal intergenerational regulation of olfactory epithelium composition in response to olfactory fear conditioning, providing insight into the heritability of acquired phenotypes.

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

Repository

Its files are read in the Code ↔ Paper reader above.

BJMarlinLab/Liff_et_al_2026

License: MIT
State: the link answers, verified on 29 September 2026
Evidence: files inventoried
Commit: cc8935bcac1028980acff49a03a51e546746420b, 25 March 2026
Languages: Jupyter (1)
Size: 3 files, 1 script
Software Heritage: not archived
Found in: “Code availability”
Holds: README, license file, 1 notebook
Not found: CITATION.cff, environment file, tests, continuous integration, documentation
Tools: Matplotlib (1 file), NumPy (1 file), pandas (1 file), SciPy (1 file), seaborn (1 file), statsmodels (1 file)
Availability: 1 check, the latest on 29 September 2026: the link answers
  • 29 September 2026: the link answers
3 files

Code availability

Code is available at https://github.com/BJMarlinLab/Liff_et_al_2026 (copy archived at Marlin Lab, 2026 (https://github.com/BJMarlinLab/Liff_et_al_2026)).

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:

  • 1 repository of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 1 script, each with its path and the digest of its content;
  • no match between paragraphs and code yet;
  • 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

Dataset available on Dryad at DOI: https://doi.org/10.5061/dryad.80gb5mm4m. The M71-RFP mouse line is available upon request.

The following dataset was generated:

Liff C, Ayman Y, Jaeger E, Cardeiro A, Lee H, Kim A, Vina-Albarracin A, Ferguson D-L, Marlin B. 2026. Fear conditioning biases olfactory sensory neuron frequencies across generations. Dryad Digital Repository.

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, 29 September 2026: the first record

Recorded: type, language, journal, volume, pages, dates, 9 authors, 1 keyword, 11 MeSH terms, 6 funders, 49 references, 2 RRIDs.

Cite

This paper

Liff, C. W., Ayman, Y. R., Jaeger, E. C., Cardeiro, A., Lee, H. S., Kim, A., Vina-Abarracin, A., Ferguson, D.-L. K., & Marlin, B. J. (2026). Fear conditioning biases olfactory sensory neuron frequencies across generations. eLife, 12, RP92882. https://doi.org/10.7554/elife.92882

BibTeX

@article{liff2026fear,
author = {Liff, Clara W and Ayman, Yasmine R and Jaeger, Eliza CB and Cardeiro, Avery and Lee, Hudson S and Kim, Alexis and Vina-Abarracin, Angelica and Ferguson, Dianne-Lee KD and Marlin, Bianca J},
title = {{Fear conditioning biases olfactory sensory neuron frequencies across generations}},
journal = {eLife},
year = {2026},
month = apr,
volume = {12},
pages = {RP92882},
publisher = {eLife Sciences Publications, Ltd},
issn = {2050-084X},
doi = {10.7554/elife.92882},
url = {https://doi.org/10.7554/elife.92882},
pmid = {41979323},
pmcid = {PMC13078775}
}

RIS

TY - JOUR
AU - Liff, Clara W
AU - Ayman, Yasmine R
AU - Jaeger, Eliza CB
AU - Cardeiro, Avery
AU - Lee, Hudson S
AU - Kim, Alexis
AU - Vina-Abarracin, Angelica
AU - Ferguson, Dianne-Lee KD
AU - Marlin, Bianca J
TI - Fear conditioning biases olfactory sensory neuron frequencies across generations
T2 - eLife
J2 - Elife
PY - 2026
DA - 2026/04/14
VL - 12
SP - RP92882
SN - 2050-084X
PB - eLife Sciences Publications, Ltd
DO - 10.7554/elife.92882
UR - https://doi.org/10.7554/elife.92882
LA - en
ER -

CSL-JSON

{
"id": "10.7554/elife.92882",
"type": "article-journal",
"title": "Fear conditioning biases olfactory sensory neuron frequencies across generations",
"container-title": "eLife",
"author": [
{
"family": "Liff",
"given": "Clara W"
},
{
"family": "Ayman",
"given": "Yasmine R"
},
{
"family": "Jaeger",
"given": "Eliza CB"
},
{
"family": "Cardeiro",
"given": "Avery"
},
{
"family": "Lee",
"given": "Hudson S"
},
{
"family": "Kim",
"given": "Alexis"
},
{
"family": "Vina-Abarracin",
"given": "Angelica"
},
{
"family": "Ferguson",
"given": "Dianne-Lee KD"
},
{
"family": "Marlin",
"given": "Bianca J"
}
],
"container-title-short": "Elife",
"volume": "12",
"page": "RP92882",
"DOI": "10.7554/elife.92882",
"PMID": "41979323",
"PMCID": "PMC13078775",
"ISSN": "2050-084X",
"publisher": "eLife Sciences Publications, Ltd",
"URL": "https://doi.org/10.7554/elife.92882",
"language": "en",
"issued": {
"date-parts": [
[
2026,
4,
14
]
]
}
}

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.1038/s41467-026-72445-1 [code]
Perception and neural representation of intermittent odor stimuli in mice.
Journal: Nature communications
In common: statsmodels, seaborn, pandas, 3 other tools, mouse, 3 references
[2] doi:10.1038/s41598-026-51082-0
Non-uniform endogenous regeneration of olfactory sensory neuron axons across the mouse olfactory bulb.
Journal: Scientific reports
In common: mouse, 5 references
[3] doi:10.1371/journal.pgen.1012090 [code]
Delta family protocadherins contribute to protoglomerular targeting of olfactory sensory neuron axons in the olfactory bulb.
Journal: PLoS genetics
In common: cellular / molecular, 5 references
[4] doi:10.1038/s41467-026-71595-6 [code]
A single-cell and spatial atlas of early human olfactory development.
Journal: Nature communications
In common: seaborn, pandas, Matplotlib, 1 other tool, 3 references
[5] doi:10.1038/s41586-026-10444-4 [code]
A brain reward circuit inhibited by next-generation weight-loss drugs in mice.
Journal: Nature
In common: seaborn, pandas, SciPy, 2 other tools, mouse, 2 references
[6] doi:10.1038/s41593-026-02262-8 [code]
Cheese3D enables sensitive detection and analysis of whole-face movement in mice.
Journal: Nature neuroscience
In common: seaborn, pandas, SciPy, 2 other tools, mouse, 2 references
[7] doi:10.1016/j.celrep.2026.117419 [code]
Conserved role of primary motor cortex in the control of prehension in mice and macaques.
Journal: Cell reports
In common: statsmodels, seaborn, pandas, 3 other tools, mouse, 1 reference
[8] 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: statsmodels, seaborn, pandas, 3 other tools, 1 reference
[9] doi:10.1038/s41586-026-10679-1 [code]
Cortical development dynamics across autism spectrum disorder mouse models.
Journal: Nature
In common: seaborn, pandas, SciPy, 2 other tools, mouse, cellular / molecular, 1 reference
[10] doi:10.1016/j.cub.2026.06.016 [code]
Neuronal RNAi and oxygen-sensing circuit shape germline resilience to heat stress.
Journal: Current biology : CB
In common: statsmodels, pandas, SciPy, 2 other tools, 1 reference

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.