OSCR

Convergent motifs of early olfactory processing are recapitulated by layer-wise efficient coding.

Code ↔ Paper

3 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 3 matches · 1 of them tie a paragraph to a whole file without given lines: a weak match, whose lines are not tinted
  1. [1] § Setup › Maximizing Mutual Information by Proxy. ↔ model.py, lines 478 to 545 · score 0.59 · mirror descent, Jensen Shannon · Report a mismatch
  2. [2] § Setup › Stimulus and Encoding Model. ↔ sample_generation/generate_binarized_gaussian_samples.py, lines 74 to 131 · score 0.55 · Gaussian copula, binary, Gamma, vector, block, matrix · Report a mismatch
  3. [3] § Results › Receptors. ↔ Figure3/functions/3rdPartyCodes/powerlaws_full_v0.0.10-2012-01-17/randht.m, the whole file · a weak match · score 0.54 · power law, tailed distribution, heavy · Report a mismatch

Paper

Loaded from Europe PMC by your browser; OSCR does not store it: doi.org · Europe PMC

The paper is loaded when this pane is shown.

The authors' code

Python · 682 lines · 26 KB · MIT · 1 match

  1. import jax
  2. import jax.numpy as jnp
  3. import matplotlib.pyplot as plt
  4. import numpy as np
  5. import flax.linen as nn
  6. import jax.tree_util as jtu
  7. from jax import vmap, lax, jit
  8. from jax.scipy.stats import norm, rankdata
  9. from jax.scipy.special import digamma
  10. from jax.scipy.special import gamma
  11. from jax.scipy.linalg import cholesky
  12. from functools import partial
  13. from typing import NamedTuple
  14. from flax import struct
  15. from functools import partial
  16. plt.rcParams["font.size"] = 20
  17. DATA_PATH = "/n/home10/jfernandezdelcasti/noncanonical-olfaction/rbm/mm_synthetic_data_non_zero_samples_9_April_2025.npy"
  18. class HyperParams(NamedTuple):
  19. N: int = 100 # number of odorants. this needs to match the number of variables (rows) in the data when using the data driven model.
  20. n: int = 2 # sparsity of odor vectors--only applies for the Qin et al 2019 odor model, which we do not use.
  21. M: int = 30 # number of odorant receptors
  22. L: int = 300 # number of olfactory receptor neurons
  23. P: int = 1000 # number of samples when we compute MI
  24. K: int = 30 # number of glomeruli (only relevant if computing with glomerular convergence)
  25. window: int = 32 # this should be set to jnp.round(jnp.sqrt(P) + 0.5).astype(int) but doing this dynamically makes hp not hashable. So change it when you change P!
  26. sigma_0: float = 0.1 # neural noise in Gaussian linear filter model
  27. sigma_c: float = 2.0 # std_dev of log normal Qin et al 2019 odorant model
  28. mu_c: float = 3.0 # mean of log normal odorant distribution. This doesn't matter mathematically, but making the odorants large helps the W optimization.
  29. W_shape: float = 1.0
  30. W_scale: str = "1 / ||c||"
  31. W_log_normal_sigma: float = 1.0
  32. W_log_normal_mu: float = -12.0 # to get to the mean in base 10 of the resulting value, multiply by .43. So this is a mean of around 10^-6.
  33. W_sparsity_mask: float = 1.0
  34. W_init: str = "normal"
  35. kappa_inv_eta_init: str = "normal"
  36. nonlinearity: str = "sigmoid log"
  37. F_max: float = 25.0
  38. hill_exponent: int = 4
  39. odor_model: str = "log normal"
  40. activity_model: str = "glomerular convergence"
  41. loss: str = "jensen_shannon_loss"
  42. phi_w: str = "identity"
  43. psi_w: str = "identity"
  44. phi_e: str = "softplus_phi"
  45. psi_e: str = "softplus_psi"
  46. phi_g: str = "identity"
  47. psi_g: str = "identity"
  48. phi_gain: str = "positive_phi"
  49. psi_gain: str = "positive_psi"
  50. phi_kappa_inv: str = "bounded_phi"
  51. psi_kappa_inv: str = "bounded_psi"
  52. phi_eta: str = "bounded_phi"
  53. psi_eta: str = "bounded_psi"
  54. sigma_kappa_inv: float = 4.0 # this is for Reddy et al. 2018 antagonism model
  55. rho: float = 0.0 # ditto
  56. canonical_E_init: bool = False
  57. balanced_E_init: bool = False
  58. noncanonical_E_init: bool = False
  59. read_in_E: bool = False
  60. canonical_G_init: bool = False
  61. read_in_G: bool = False
  62. initial_gain: float = 1.0
  63. binarize_c_for_MI_computation: bool = False
  64. W_path: str = "/n/home10/jfernandezdelcasti/noncanonical-olfaction/model/results/scratch/W_final_0.npy" # only applies if W_init == "read in"
  65. E_path: str = "/n/home10/jfernandezdelcasti/noncanonical-olfaction/model/results/expression/E_noncanonical_init.npy" # only applies if hp.read_in_E is True
  66. G_path: str = "/n/home10/jfernandezdelcasti/noncanonical-olfaction/model/results/glomerular_convergence/G_final_0.npy" # only applies if read_in_G == True
  67. class TrainingConfig(NamedTuple):
  68. scans: int = 10
  69. epochs_per_scan: int = 100
  70. gamma_W: float = 0.1
  71. gamma_E: float = 0.1
  72. gamma_G: float = 0.1
  73. gamma_gain: float = 0.1
  74. gamma_kappa_inv: float = 0.1
  75. gamma_eta: float = 0.1
  76. gamma_T: float = 0.1
  77. T_hidden_dim: int = 128
  78. T_mode: str = "inner_product"
  79. class LoggingConfig(NamedTuple):
  80. log_interval: int = None
  81. save_model: bool = False
  82. output_dir: str = "."
  83. config_id: str = "0"
  84. class FullConfig(NamedTuple):
  85. hyperparams: HyperParams
  86. training: TrainingConfig
  87. logging: LoggingConfig
  88. seed: int = 0
  89. data_path: str = ''
  90. @struct.dataclass
  91. class Params:
  92. W: jnp.ndarray
  93. E: jnp.ndarray
  94. G: jnp.ndarray
  95. gain: jnp.float32
  96. kappa_inv: jnp.ndarray
  97. eta: jnp.ndarray
  98. class T_estimator(nn.Module):
  99. hidden_dim: int = 128
  100. mode: str = "inner_product"
  101. def g_f(self, v):
  102. return jnp.clip(
  103. jnp.log(2) - jnp.log(1 + jnp.exp(-v)), max=jnp.log(2) - 1e-6
  104. ) # see Table 2 of https://arxiv.org/pdf/1606.00709
  105. @nn.compact
  106. def __call__(self, c, r):
  107. # batch should be the first dimension here. this is contra everything else in our pipeline, so be careful! transpose just before passing in c and r
  108. if self.mode == "inner_product":
  109. r = nn.Dense(self.hidden_dim)(r)
  110. r = nn.tanh(r)
  111. r = nn.Dense(self.hidden_dim)(r)
  112. r = nn.tanh(r)
  113. c = nn.Dense(self.hidden_dim)(c)
  114. c = nn.tanh(c)
  115. c = nn.Dense(self.hidden_dim)(c)
  116. c = nn.tanh(c)
  117. v = jnp.einsum("bi,bi->b", c, r) # or use vmap(jnp.dot)(c, r)
  118. return self.g_f(v)
  119. elif self.mode == "concatenate":
  120. # Concatenate c (P, N) and r (P, L) -> (P, N + L)
  121. x = jnp.concatenate([c, r], axis=1)
  122. x = nn.Dense(self.hidden_dim)(x)
  123. x = nn.tanh(x)
  124. x = nn.Dense(self.hidden_dim)(x)
  125. x = nn.tanh(x)
  126. v = nn.Dense(1)(x)
  127. return self.g_f(v)
  128. @struct.dataclass
  129. class TrainingState:
  130. p: any # Your primal parameters
  131. Tp: any # MINE network parameters
  132. key: jax.Array # PRNG key
  133. metrics: dict
  134. def create_one_hot_array(key, L, M):
  135. indices = jax.random.randint(key, (L,), 0, M)
  136. return jax.nn.one_hot(indices, M, dtype=float)
  137. def sigmoid_log(x, hp):
  138. return x / (jnp.abs(x) + 1) # for the cases where x can be negative
  139. def hill_function(x, hp):
  140. x_safe = jnp.clip(x, a_min=1e-10, a_max=None) # Avoid division by zero and extreme values
  141. denom = 1 + (1 / x_safe)**hp.hill_exponent
  142. return 1 / denom
  143. def make_noncanonical_E(key, hp, lower=1, upper=7):
  144. E_rows = []
  145. keys = jax.random.split(key, hp.L)
  146. for i in range(hp.L):
  147. subkey1, subkey2, subkey3 = jax.random.split(keys[i], 3)
  148. k = jax.random.randint(subkey1, shape=(), minval=lower, maxval=upper + 1)
  149. perm = jax.random.permutation(subkey2, hp.M)
  150. indices = perm[:k]
  151. values = jax.random.uniform(subkey3, shape=(k,))
  152. row = jnp.zeros(hp.M)
  153. row = row.at[indices].set(values)
  154. row /= jnp.sum(row)
  155. E_rows.append(row)
  156. E = jnp.stack(E_rows)
  157. return E
  158. def initialize_p(rng, mean_norm_c=None, threshold=None, hp=None) -> Params:
  159. if hp is None:
  160. hp = HyperParams()
  161. W_key, E_key, G_key, z_key, eta_key = jax.random.split(rng, 5)
  162. phi_E = PHI_PSI_REGISTRY[hp.phi_e]
  163. phi_G = PHI_PSI_REGISTRY[hp.phi_g]
  164. phi_W = PHI_PSI_REGISTRY[hp.phi_w]
  165. phi_kappa_inv = PHI_PSI_REGISTRY[hp.phi_kappa_inv]
  166. phi_eta = PHI_PSI_REGISTRY[hp.phi_eta]
  167. if hp.W_init == "gamma":
  168. if isinstance(hp.W_scale, float):
  169. W = float(hp.W_scale) * phi_W(jnp.clip(
  170. jax.random.gamma(W_key, a=hp.W_shape, shape=(hp.M, hp.N)),
  171. min=1e-9,
  172. max=1 - 1e-9,
  173. ))
  174. elif hp.W_scale == "1 / ||c||":
  175. W = 1 / mean_norm_c * phi_W(jnp.clip(
  176. jax.random.gamma(W_key, a=hp.W_shape, shape=(hp.M, hp.N)),
  177. min=1e-9,
  178. max=1 - 1e-9,
  179. ))
  180. elif hp.W_init == "read in":
  181. W = jnp.clip(jnp.load(hp.W_path), min=1e-16)
  182. elif hp.W_init == "normal":
  183. if hp.W_scale == "1 / ||c||":
  184. W = (1 / mean_norm_c) * phi_W(jax.random.normal(W_key, shape=(hp.M, hp.N)))
  185. if hp.W_scale == "threshold":
  186. W = threshold * phi_W(jax.random.normal(W_key, shape=(hp.M, hp.N)))
  187. elif hp.W_scale == "1 / root(N)":
  188. W = 1 / jnp.sqrt(hp.N) * phi_W(jax.random.normal(W_key, shape=(hp.M, hp.N))) # this is a bad scaling because c is log-normal distributed and sparse
  189. elif hp.W_init == "log normal":
  190. key, W_key, mask_key = jax.random.split(W_key, 3)
  191. Z = jax.random.normal(W_key, shape=(hp.M, hp.N))
  192. mask = jax.random.bernoulli(mask_key, p = hp.W_sparsity_mask, shape=(hp.M, hp.N))
  193. if hp.W_scale == "1 / ||c||":
  194. W = jnp.clip((1 / mean_norm_c) * jnp.exp(hp.W_log_normal_sigma * Z) * mask, min=1e-16, max=1e-1)
  195. if hp.W_scale == "threshold":
  196. W = jnp.clip(threshold * jnp.exp(hp.W_log_normal_sigma * Z) * mask, min=1e-16, max=1e-1)
  197. if hp.W_scale == "custom":
  198. W = jnp.clip(jnp.exp(hp.W_log_normal_sigma * Z + hp.W_log_normal_mu) * mask, min=1e-16, max=1e-1)
  199. if hp.canonical_E_init:
  200. if hp.balanced_E_init:
  201. try:
  202. E = jnp.repeat(
  203. jnp.eye(hp.M), hp.L // hp.M, axis=0
  204. ).astype(float) # this is a canonical initialization with exactly equal numbers representing each receptor
  205. except:
  206. raise ValueError(
  207. "For balanced canonical initialization, set L to a postive multiple of M"
  208. )
  209. else:
  210. E = create_one_hot_array(E_key, hp.L, hp.M)
  211. elif hp.read_in_E:
  212. E = jnp.load(hp.E_path)
  213. elif hp.noncanonical_E_init:
  214. E = make_noncanonical_E(E_key, hp) # no softmax here: set lower to upper (3 to 7) entries to be non-zero, and divide by row sum.
  215. else: # random init
  216. E = phi_E(
  217. 0.5 + 0.1 * jax.random.normal(E_key, shape=(hp.L, hp.M))
  218. ) # this is a random initialization where every neuron expresses roughly the same amount of every receptor.
  219. if hp.canonical_G_init:
  220. G = E.T # this only works if number of receptors (hp.M) == number of glomeruli (hp.K)
  221. elif hp.read_in_G:
  222. G = jnp.load(hp.G_path)
  223. else:
  224. G = phi_G(1/hp.L * jax.random.normal(G_key, shape=(hp.K, hp.L))) # this scaling is key. Otherwise the scale of g is enormous compared to r, and loss = 0 no matter what. the signal is swamped.
  225. gain = hp.initial_gain
  226. if hp.kappa_inv_eta_init == "normal":
  227. kappa_inv = phi_kappa_inv(jax.random.normal(eta_key, shape=(hp.M, hp.N)))
  228. eta = phi_eta(jax.random.normal(z_key, shape=(hp.M, hp.N)))
  229. elif hp.kappa_inv_eta_init == "near zero":
  230. kappa_inv = phi_kappa_inv(jax.random.normal(eta_key, shape=(hp.M, hp.N)) - 5)
  231. eta = phi_eta(jax.random.normal(z_key, shape=(hp.M, hp.N)) - 5)
  232. else: # kappa_inv = jax.random.lognormal(kappa_inv_key, sigma=sigma_kappa_inv, shape=(hp.M, hp.N)) # see "Olfactory Encoding Model" in Reddy and Zak 2018
  233. eta = jax.random.lognormal(eta_key, shape=(hp.M, hp.N))
  234. z = jax.random.normal(z_key, shape=(hp.M, hp.N))
  235. kappa_inv = jnp.exp(
  236. hp.sigma_kappa_inv * (hp.rho * jnp.log(eta) + jnp.sqrt(1 - hp.rho**2) * z)
  237. )
  238. # kappa_inv and eta are now both positive, so no need to pass them through phi
  239. return hp, Params(W, E, G, gain, kappa_inv, eta)
  240. def initialize_training_state(subkey, hp, p_init, training_config):
  241. subkey_cs, subkey_activity, subkey_T, subkey_state = jax.random.split(subkey, 4)
  242. T = T_estimator(
  243. hidden_dim = training_config.T_hidden_dim,
  244. mode = training_config.T_mode
  245. )
  246. draw_cs = ODOR_MODEL_REGISTRY[hp.odor_model]
  247. activity_function = ACTIVITY_FUNCTION_REGISTRY[hp.activity_model]
  248. cs = draw_cs(subkey_cs, hp)
  249. r = activity_function(hp, p_init, cs, subkey_activity)
  250. if hp.loss == "jensen_shannon_loss":
  251. Tp_init = T.init(subkey_T, cs.T, r.T) # this is for variational MI estimation
  252. else:
  253. Tp_init = 0 # this is a dummy for Gaussian MI estimation (not variational, so no T network needed)
  254. init_state = TrainingState(p_init, Tp_init, subkey_state, metrics={"mi": 0.0})
  255. return init_state
  256. def draw_c_sparse_log_normal(subkey, hp):
  257. c = jnp.zeros(hp.N)
  258. sub1, sub2 = jax.random.split(subkey)
  259. non_zero_indices = jax.random.choice(sub1, hp.N, shape=(hp.n,), replace=False)
  260. concentrations = jnp.exp(hp.mu_c) * jax.random.lognormal(sub2, sigma=hp.sigma_c, shape=(hp.n,))
  261. c = c.at[non_zero_indices].set(concentrations)
  262. return c
  263. @partial(jax.jit, static_argnames=['hp'])
  264. def draw_cs_sparse_log_normal(subkey, hp):
  265. keybatch = jax.random.split(subkey, hp.P)
  266. cs = vmap(draw_c_sparse_log_normal, in_axes=(0, None))(keybatch, hp).T
  267. return cs
  268. def closure_draw_cs_data_driven(
  269. path,
  270. ): # closures are nice https://apxml.com/courses/advanced-jax/chapter-1-advanced-jax-transformations-control-flow/closures-jax-staging
  271. loaded_data = jnp.load(path)
  272. P_total = loaded_data.shape[1]
  273. def draw_cs_data_driven_binary(subkey, hp):
  274. idx = jax.random.choice(subkey, P_total, shape=(hp.P,), replace=True)
  275. batch = jax.device_put(loaded_data[:, idx]) # move to device only once per call
  276. return batch # shape (N, P), like the log_normal one
  277. def draw_cs_data_driven_log_normal(subkey, hp):
  278. subkey_index, subkey_concentration = jax.random.split(subkey)
  279. idx = jax.random.choice(subkey_index, P_total, shape=(hp.P,), replace=True)
  280. batch = jax.device_put(loaded_data[:, idx]) # move to device only once per call
  281. batch = jnp.where(
  282. batch,
  283. jnp.exp(hp.mu_c) * jax.random.lognormal(
  284. subkey_concentration, sigma=hp.sigma_c, shape=batch.shape
  285. ),
  286. jnp.zeros(batch.shape),
  287. )
  288. return batch
  289. return draw_cs_data_driven_binary, draw_cs_data_driven_log_normal
  290. draw_cs_data_driven_binary, draw_cs_data_driven_log_normal = (
  291. closure_draw_cs_data_driven(DATA_PATH)
  292. )
  293. def compute_receptor_activity(subkey, hp, p, c):
  294. pre_activations = p.W @ c
  295. nl = NL_REGISTRY[hp.nonlinearity]
  296. r = nl(pre_activations) + hp.sigma_0 * jax.random.normal(
  297. subkey, shape=pre_activations.shape
  298. )
  299. return r
  300. def compute_linear_filter_activity(hp, p, c, subkey):
  301. receptor_activations = p.W @ c # p.W is receptor x odorant
  302. c_AMP = p.E @ receptor_activations # p.E is neuron x receptor
  303. nl = NL_REGISTRY[hp.nonlinearity]
  304. r = nl(c_AMP, hp) + hp.sigma_0 * jax.random.normal(subkey, shape=c_AMP.shape)
  305. return r
  306. def linear_filter_plus_glomerular_layer(hp, p, c, subkey):
  307. r = compute_linear_filter_activity(hp, p, c, subkey)
  308. g = p.G @ jnp.tanh(p.gain * r)
  309. return g
  310. def compute_osn_firing_rate_with_antagonism(hp, p, c):
  311. """generalized version of equation 14 in Reddy, Zak et al. 2018. eta and kappa_inv are receptor x odorant, and the gene expression matrix p.E is neuron x receptor"""
  312. receptor_induced_activities = p.E @ (
  313. jnp.matmul((p.eta * p.kappa_inv), c) / (1 + jnp.matmul(p.kappa_inv, c))
  314. )
  315. denominator = 1 + receptor_induced_activities**-hp.hill_exponent
  316. F = hp.F_max / denominator
  317. return F
  318. def compute_osn_firing_with_antagonism(hp, p, c, subkey):
  319. firing_rate = compute_osn_firing_rate_with_antagonism(hp, p, c)
  320. # firing = jax.random.poisson(subkey, lam=firing_rate)
  321. firing = firing_rate + hp.sigma_0 * jax.random.normal(
  322. subkey, shape=firing_rate.shape
  323. )
  324. return firing # this should be neuron x samples, where c is odorants x samples
  325. def compute_entropy(r, hp):
  326. entropy = compute_sum_of_marginal_entropies(r, hp) - compute_information(r)
  327. return entropy
  328. def compute_sum_of_marginal_entropies(r, hp):
  329. compute_entropy_vmap = vmap(vasicek_entropy, in_axes=(0, None))
  330. marginal_entropies = compute_entropy_vmap(r, hp)
  331. return jnp.sum(marginal_entropies)
  332. def vasicek_entropy(
  333. X, hp
  334. ): # see https://github.com/scipy/scipy/blob/main/scipy/stats/_entropy.py#L378
  335. n = X.shape[-1]
  336. X = jnp.sort(X, axis=-1)
  337. X = pad_along_last_axis(X, hp.window)
  338. start1 = 2 * hp.window
  339. length = hp.P
  340. differences = lax.dynamic_slice(X, (start1,), (length,)) - lax.dynamic_slice(
  341. X, (0,), (length,)
  342. )
  343. logs = jnp.log(n / (2 * hp.window) * differences)
  344. return jnp.mean(logs, axis=-1)
  345. def pad_along_last_axis(X, window):
  346. first_value = X[0]
  347. last_value = X[-1]
  348. Xl = lax.full_like(x=jnp.empty((window,)), fill_value=first_value)
  349. Xr = lax.full_like(x=jnp.empty((window,)), fill_value=last_value)
  350. return jnp.concatenate((Xl, X, Xr))
  351. def compute_information(r):
  352. M, P = r.shape
  353. G = norm.ppf(
  354. (rankdata(r.T, axis=0) / (P + 1)), loc=0, scale=1
  355. ) # this is just ranking the data and making it normally distributed.
  356. bias_correction = 0.5 * jnp.sum(
  357. digamma((P - jnp.arange(1, M + 1) + 1) / 2) - jnp.log(P / 2)
  358. )
  359. cov_matrix = jnp.cov(G, rowvar=False)
  360. chol_decomp = cholesky(cov_matrix)
  361. log_det = jnp.sum(jnp.log(jnp.diag(chol_decomp)))
  362. I = -(
  363. log_det - bias_correction
  364. ) # remember: entropy overall is sum of marginals minus information.
  365. return I
  366. @partial(jax.jit, static_argnames=["hp"])
  367. def entropy_loss(p, hp, cs, subkey):
  368. r = compute_linear_filter_activity(subkey, hp, p, cs)
  369. e = compute_entropy(r, hp)
  370. return -e
  371. @partial(jax.jit, static_argnames=["hp"])
  372. def log_det_loss(p, hp, cs, subkey):
  373. r = compute_linear_filter_activity(subkey, hp, p, cs)
  374. cov = jnp.cov(r, rowvar=True)
  375. chol_decomp = cholesky(cov)
  376. log_det = jnp.sum(jnp.log(jnp.diag(chol_decomp)))
  377. return -log_det
  378. def f_star_JSD(t): # see Table 2 of https://arxiv.org/pdf/1606.00709
  379. return -jnp.log(2 - jnp.exp(t))
  380. def jensen_shannon_loss(p, Tp, hp, cs, key):
  381. key, subkey_firing, subkey_shuffle = jax.random.split(key, 3)
  382. activity_function = ACTIVITY_FUNCTION_REGISTRY[hp.activity_model]
  383. r = activity_function(hp, p, cs, subkey_firing)
  384. r_shuffled = jax.random.permutation(subkey_shuffle, r, axis=1)
  385. if hp.binarize_c_for_MI_computation:
  386. cs = (cs != 0).astype(float)
  387. T_joint = T_estimator().apply(Tp, cs.T, r.T)
  388. T_marginal = T_estimator().apply(Tp, cs.T, r_shuffled.T)
  389. mi_estimate = jnp.mean(T_joint) - jnp.mean(f_star_JSD(T_marginal))
  390. return -mi_estimate
  391. def flatten_metrics(metrics_list):
  392. """Convert list of metric dicts to dict of arrays."""
  393. flattened = {k: [] for k in metrics_list[0].keys()} # Initialize with keys
  394. for metrics in metrics_list:
  395. for k, v in metrics.items():
  396. flattened[k].extend(v)
  397. return {k: jnp.array(v) for k, v in flattened.items()}
  398. @partial(jax.jit, static_argnames=("hp"))
  399. def update_params_T(p, Tp, hp, cs, key, gammas):
  400. # Split keys
  401. key, loss_key = jax.random.split(key)
  402. # parse gammas
  403. gamma_W, gamma_E, gamma_G, gamma_gain, gamma_kappa_inv, gamma_eta, gamma_T = gammas
  404. # Compute gradients for MINE network (T) and model params (E)
  405. if hp.loss != "jensen_shannon_loss":
  406. raise ValueError(
  407. "only the jensen shannon loss requires a T network. For other losses, use update_params"
  408. )
  409. loss = LOSS_REGISTRY[hp.loss]
  410. phi_W = PHI_PSI_REGISTRY[hp.phi_w]
  411. psi_W = PHI_PSI_REGISTRY[hp.psi_w]
  412. phi_E = PHI_PSI_REGISTRY[hp.phi_e]
  413. psi_E = PHI_PSI_REGISTRY[hp.psi_e]
  414. phi_G = PHI_PSI_REGISTRY[hp.phi_g]
  415. psi_G = PHI_PSI_REGISTRY[hp.psi_g]
  416. phi_gain = PHI_PSI_REGISTRY[hp.phi_gain]
  417. psi_gain = PHI_PSI_REGISTRY[hp.psi_gain]
  418. phi_kappa_inv = PHI_PSI_REGISTRY[hp.phi_kappa_inv]
  419. psi_kappa_inv = PHI_PSI_REGISTRY[hp.psi_kappa_inv]
  420. phi_eta = PHI_PSI_REGISTRY[hp.phi_eta]
  421. psi_eta = PHI_PSI_REGISTRY[hp.psi_eta]
  422. mi_estimate, grads = jax.value_and_grad(loss, argnums=(0, 1))(
  423. p, Tp, hp, cs, loss_key
  424. )
  425. grads_p, grads_T = grads
  426. # Update MINE network (T)
  427. Tp = jax.tree.map(lambda param, grad: param - gamma_T * grad, Tp, grads_T)
  428. # natural gradient in dual space is equivalent to mirror descent in primal space. A "straight through" trick.
  429. # phi:unconstrained --> constrained, psi is inverse of phi.
  430. # see https://vene.ro/blog/mirror-descent.html#generalizing-the-projected-gradient-method-with-divergences for a very clear exposition
  431. def update_param(p, param_name, psi, phi, grads, gamma, epsilon=1e-8):
  432. # Map to dual space
  433. U_current = psi(getattr(p, param_name))
  434. # Normalize the gradient
  435. grad = getattr(grads, param_name)
  436. grad_norm = jnp.linalg.norm(grad)
  437. normalized_grad = grad / (grad_norm + epsilon)
  438. # Gradient step in dual space
  439. U_new = U_current - gamma * normalized_grad
  440. # Map back to primal space
  441. new_value = phi(U_new)
  442. # Replace in parameter object
  443. return p.replace(**{param_name: new_value})
  444. # Apply update for each parameter
  445. p = update_param(p, "W", psi_W, phi_W, grads_p, gamma_W)
  446. p = update_param(p, "E", psi_E, phi_E, grads_p, gamma_E)
  447. p = update_param(p, "G", psi_G, phi_G, grads_p, gamma_G)
  448. p = update_param(p, "gain", psi_gain, phi_gain, grads_p, gamma_gain)
  449. p = update_param(p, "kappa_inv", psi_kappa_inv, phi_kappa_inv, grads_p, gamma_kappa_inv)
  450. p = update_param(p, "eta", psi_eta, phi_eta, grads_p, gamma_eta)
  451. return p, Tp, -mi_estimate
  452. def update_params(p, hp, cs, key, gamma_E):
  453. ''' this is old--comes from estimating MI directly a la Qin et al. 2019, not using the variational formulation '''
  454. # Split keys
  455. key, loss_key = jax.random.split(key)
  456. loss = LOSS_REGISTRY[hp.loss]
  457. phi = PHI_PSI_REGISTRY[hp.phi]
  458. psi = PHI_PSI_REGISTRY[hp.psi]
  459. # Compute gradients for MINE network (T) and model params (E)
  460. mi_estimate, grads_p = jax.value_and_grad(loss, argnums=(0))(
  461. p, hp, cs, loss_key
  462. ) # loss needs to have this signature
  463. # natural gradient in dual space is equivalent to mirror descent in primal space. A "straight through" trick.
  464. # phi:unconstrained --> constrained, psi is inverse of phi.
  465. # see https://vene.ro/blog/mirror-descent.html#generalizing-the-projected-gradient-method-with-divergences for a very clear exposition
  466. U_current = psi(p.E) # map to dual space
  467. U_new = (
  468. U_current - gamma_E * grads_p.E
  469. ) # grads_p.E is correctly with respect to the primal parameters and evaluated on the primal parameters
  470. E_new = phi(U_new) # map back to primal space
  471. p = p.replace(E=E_new)
  472. return p, mi_estimate
  473. def scan_step(state, hp, gammas):
  474. key, subkey_odors, subkey_loss = jax.random.split(state.key, 3)
  475. draw_cs = ODOR_MODEL_REGISTRY[hp.odor_model]
  476. cs = draw_cs(subkey_odors, hp)
  477. if hp.loss == "jensen_shannon_loss":
  478. p_new, Tp_new, mi_estimate = update_params_T(
  479. state.p, state.Tp, hp, cs, subkey_loss, gammas
  480. )
  481. else:
  482. p_new, mi_estimate = update_params(state.p, hp, cs, subkey_loss, gammas[0])
  483. Tp_new = 0
  484. new_metrics = {"mi": mi_estimate.astype(float)}
  485. return TrainingState(p_new, Tp_new, key, new_metrics), new_metrics
  486. # @partial(jax.jit, static_argnames=('hp', 'epochs_per_scan'))
  487. def scan_phase(state, hp, gammas, epochs_per_scan):
  488. """Process multiple epochs in a single scanned phase"""
  489. # Scan over epochs_per_scan steps
  490. def scan_closure(
  491. state, _
  492. ): # this is just a trick so you can pass hp into your scan_step.
  493. state, new_metrics = scan_step(state, hp, gammas)
  494. return state, new_metrics
  495. final_state, metrics = jax.lax.scan(
  496. scan_closure, state, length=epochs_per_scan
  497. )
  498. return final_state, metrics
  499. def train_natural_gradient_scan_over_epochs(
  500. initial_state, hp, gammas, scans=2, epochs_per_scan=2
  501. ):
  502. num_total_epochs = scans * epochs_per_scan
  503. metrics_store = {
  504. "mi": jnp.zeros(num_total_epochs)
  505. }
  506. epoch_counter = 0
  507. for scan_idx in range(scans):
  508. state, scan_metrics = scan_phase(
  509. initial_state, hp, gammas, epochs_per_scan
  510. )
  511. scan_metrics = jax.device_get(scan_metrics) # ensure it’s on host
  512. mi_vals = scan_metrics["mi"] # shape: (epochs_per_scan,)
  513. metrics_store["mi"] = metrics_store["mi"].at[epoch_counter:epoch_counter + epochs_per_scan].set(mi_vals)
  514. epoch_counter += epochs_per_scan
  515. if scan_idx % 1 == 0:
  516. avg_mi = jnp.mean(jnp.array(scan_metrics["mi"]))
  517. print(f"Scan {scan_idx}: Avg MI {avg_mi:.3f}")
  518. initial_state = state
  519. return initial_state, metrics_store
  520. def make_constant_gammas(epochs_per_scan, scans, gamma_W, gamma_E, gamma_G, gamma_gain, gamma_kappa_inv, gamma_eta, gamma_T):
  521. gamma_Ws = [gamma_W] * (epochs_per_scan * scans)
  522. gamma_Es = [gamma_E] * (epochs_per_scan * scans)
  523. gamma_Gs = [gamma_G] * (epochs_per_scan * scans)
  524. gamma_gains = [gamma_gain] * (epochs_per_scan * scans)
  525. gamma_kappa_invs = [gamma_kappa_inv] * (epochs_per_scan * scans)
  526. gamma_etas = [gamma_eta] * (epochs_per_scan * scans)
  527. gamma_Ts = [gamma_T] * (epochs_per_scan * scans)
  528. return jnp.array([gamma_Ws, gamma_Es, gamma_Gs, gamma_kappa_invs, gamma_etas, gamma_Ts]).T
  529. NL_REGISTRY = {
  530. "sigmoid log": sigmoid_log,
  531. "hill function": hill_function,
  532. "relu": jax.nn.relu,
  533. "tanh": jax.nn.tanh,
  534. "id": lambda x: x,
  535. }
  536. ACTIVITY_FUNCTION_REGISTRY = {
  537. "linear filter": compute_linear_filter_activity,
  538. "antagonism": compute_osn_firing_with_antagonism,
  539. "glomerular convergence": linear_filter_plus_glomerular_layer
  540. }
  541. PHI_PSI_REGISTRY = {
  542. "softplus_phi": lambda u: jax.nn.softmax(
  543. u, axis=1
  544. ), # rows of expression matrix should sum to 1.
  545. "softplus_psi": lambda x: jnp.log(jnp.clip(x, min=1e-6, max=1 - 1e-6)),
  546. "bounded_phi": lambda u: 1 / (1 + jnp.exp(-u)),
  547. "bounded_psi": lambda x: jnp.log(
  548. jnp.clip(x, min=1e-6, max=1 - 1e-6) / (1 - jnp.clip(x, 1e-6, 1 - 1e-6))
  549. ),
  550. "positive_phi": jnp.exp,
  551. "positive_psi": jnp.log,
  552. "identity": lambda x: x
  553. }
  554. LOSS_REGISTRY = {
  555. "jensen_shannon_loss": jensen_shannon_loss,
  556. "entropy_loss": entropy_loss,
  557. "log_det_loss": log_det_loss,
  558. }
  559. ODOR_MODEL_REGISTRY = {
  560. "data-driven binary": draw_cs_data_driven_binary,
  561. "data-driven log normal": draw_cs_data_driven_log_normal,
  562. "log normal": draw_cs_sparse_log_normal,
  563. }

model.py at commit 8c23d3a, under MIT · at the source

Overview

  1. Center for Brain Science, Harvard University, Cambridge, MA 02138
  2. Program in Biophysics, Harvard University, Cambridge, MA 02138
  3. Department of Molecular and Cellular Biology, Harvard University, Cambridge, MA 02138
  4. Kempner Institute for the Study of Natural and Artificial Intelligence, Harvard University, Cambridge, MA 02138
  5. Society of Fellows, Harvard University, Cambridge, MA 02138
Institutions: Harvard University (United States)
Dates: received 4 September 2025; accepted 23 February 2026; published online 24 March 2026; in print 31 March 2026
Type: Research article · Language: English
License: CC BY-NC-ND
Identifiers: DOI 10.1073/pnas.2524661123 · PMID 41875150 · PMCID PMC13037853 · OpenAlex W7140220544
Open access: hybrid, a free copy (OpenAlex)
Status: code verified (how the code is found and verified)
Categories: mouse (organism), cellular / molecular (subfield)
Keywords: olfaction, efficient coding, convergent evolution
MeSH: Olfactory Pathways*, Olfactory Receptor Neurons*, Smell*, Animals, Biological Evolution, Mice, Models, Neurological, Receptors, Odorant (* major topic)
Topic: Olfactory and Sensory Function Studies (Sensory Systems, Neuroscience), according to OpenAlex
Funding: Harvard | Society of Fellows, Harvard University (Society of Fellows) (N/A); NTT Research (N/A, A47994); NIGMS NIH HHS (T32 GM158477); NIH HHS (DP5 OD037354); HHS | National Institutes of Health (DP5OD037354, T32GM158477)
Citations: cited by 2 papers (Europe PMC); 103 references in the paper

Abstract

The abstract is not reproduced here: the paper's license (CC BY-NC-ND) does not allow it. Read it in the paper, at the publisher or on Europe PMC.

Repositories

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

VNMurthyLab/olfactory-ec

License: MIT
State: the link answers, verified on 30 September 2026
Evidence: files inventoried
Commit: 8c23d3af55f7e7573bb34483ab8ddbb229477c1f, 24 March 2026
Languages: Python (27), Shell (1)
Size: 2,472 files, 28 scripts
Software Heritage: not archived
Found in: “Data, Materials, and Software Availability”
Holds: README, license file
Not found: CITATION.cff, environment file, tests, continuous integration, documentation
Tools: JAX (27 files), Matplotlib (27 files), NumPy (7 files), SciPy (5 files)
Availability: 1 check, the latest on 30 September 2026: the link answers
  • 30 September 2026: the link answers
30 files

samuellab/larval-orn

License: MIT
State: the link answers, verified on 30 September 2026
Evidence: files inventoried
Commit: 1618850795214b9d1649caccc5ab96fbc9ada7ba, 31 March 2020
Languages: MATLAB (39)
Size: 102 files, 39 scripts
Software Heritage: not archived
Found in: “Data, Materials, and Software Availability”
Holds: README, license file
Not found: CITATION.cff, environment file, tests, continuous integration, documentation
Availability: 1 check, the latest on 30 September 2026: the link answers
  • 30 September 2026: the link answers
41 files

The paper's code and data availability statement is in the Data section.

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;
  • 67 scripts, each with its path and the digest of its content;
  • 3 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

No dataset and no data link were found in the paper.

Code and data availability statement

The paper has a code and data availability statement. Its license (CC BY-NC-ND) does not allow reproducing it here; in short, from what the harvester recognized in it:

Read it in the paper: doi.org/10.1073/pnas.2524661123.

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, 4 authors, 3 keywords, 8 MeSH terms, 5 funders, 91 references.

Cite

This paper

del Castillo, J. C. F., Pashakhanloo, F., Murthy, V. N., & Zavatone-Veth, J. A. (2026). Convergent motifs of early olfactory processing are recapitulated by layer-wise efficient coding. Proceedings of the National Academy of Sciences of the United States of America, 123(13), e2524661123. https://doi.org/10.1073/pnas.2524661123

BibTeX

@article{delcastillo2026convergent,
author = {del Castillo, Juan Carlos Fernández and Pashakhanloo, Farhad and Murthy, Venkatesh N and Zavatone-Veth, Jacob A},
title = {{Convergent motifs of early olfactory processing are recapitulated by layer-wise efficient coding}},
journal = {Proceedings of the National Academy of Sciences of the United States of America},
year = {2026},
month = mar,
volume = {123},
number = {13},
pages = {e2524661123},
publisher = {National Academy of Sciences},
issn = {0027-8424},
doi = {10.1073/pnas.2524661123},
url = {https://doi.org/10.1073/pnas.2524661123},
pmid = {41875150},
pmcid = {PMC13037853}
}

RIS

TY - JOUR
AU - del Castillo, Juan Carlos Fernández
AU - Pashakhanloo, Farhad
AU - Murthy, Venkatesh N
AU - Zavatone-Veth, Jacob A
TI - Convergent motifs of early olfactory processing are recapitulated by layer-wise efficient coding
T2 - Proceedings of the National Academy of Sciences of the United States of America
J2 - Proc Natl Acad Sci U S A
PY - 2026
DA - 2026/03/24
VL - 123
IS - 13
SP - e2524661123
SN - 0027-8424
PB - National Academy of Sciences
DO - 10.1073/pnas.2524661123
UR - https://doi.org/10.1073/pnas.2524661123
LA - en
ER -

CSL-JSON

{
"id": "10.1073/pnas.2524661123",
"type": "article-journal",
"title": "Convergent motifs of early olfactory processing are recapitulated by layer-wise efficient coding",
"container-title": "Proceedings of the National Academy of Sciences of the United States of America",
"author": [
{
"family": "del Castillo",
"given": "Juan Carlos Fernández"
},
{
"family": "Pashakhanloo",
"given": "Farhad"
},
{
"family": "Murthy",
"given": "Venkatesh N"
},
{
"family": "Zavatone-Veth",
"given": "Jacob A"
}
],
"container-title-short": "Proc Natl Acad Sci U S A",
"volume": "123",
"issue": "13",
"page": "e2524661123",
"DOI": "10.1073/pnas.2524661123",
"PMID": "41875150",
"PMCID": "PMC13037853",
"ISSN": "0027-8424",
"publisher": "National Academy of Sciences",
"URL": "https://doi.org/10.1073/pnas.2524661123",
"language": "en",
"issued": {
"date-parts": [
[
2026,
3,
24
]
]
}
}

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.1073/pnas.2508439122 [code]
When noncanonical olfaction is optimal.
Journal: Proceedings of the National Academy of Sciences of the United States of America
In common: SciPy, Matplotlib, NumPy, 13 references
[2] doi:10.1038/s41467-024-47366-6 [code]
Distinct information conveyed to the olfactory bulb by feedforward input from the nose and feedback from the cortex.
Journal: Nature communications
In common: mouse, cellular / molecular, 8 references, author Venkatesh N Murthy
[3] doi:10.1371/journal.pcbi.1012379 [code]
The primacy model and the structure of olfactory space.
Journal: PLoS computational biology
In common: Statistics and Machine Learning Toolbox, cellular / molecular, 10 references
[4] doi:10.1038/s44319-025-00476-8
An integrated anatomical, functional and evolutionary view of the Drosophila olfactory system.
Journal: EMBO reports
In common: cellular / molecular, 11 references
[5] doi:10.1371/journal.pbio.3001677 [code]
Context-dependent reversal of odorant preference is driven by inversion of the response in a single sensory neuron type.
Journal: PLoS biology
In common: cellular / molecular, 10 references
[6] doi:10.1016/j.isci.2024.111317 [code]
Gene expansion in the hawkmoth <i>Manduca sexta</i> drives evolution of food-associated odorant receptors.
Journal: iScience
In common: cellular / molecular, 10 references
[7] doi:10.1038/s41467-026-72445-1 [code]
Perception and neural representation of intermittent odor stimuli in mice.
Journal: Nature communications
In common: SciPy, Matplotlib, NumPy, mouse, 5 references, author Venkatesh N Murthy
[8] doi:10.1093/molbev/msaf080 [code]
Evolutionary Process Underlying Receptor Gene Expansion and Cellular Divergence of Olfactory Sensory Neurons in Honeybees.
Journal: Molecular biology and evolution
In common: cellular / molecular, 9 references
[9] doi:10.7554/elife.80470 [code]
Mapping odorant sensitivities reveals a sparse but structured representation of olfactory chemical space by sensory input to the mouse olfactory bulb.
Journal: eLife
In common: Statistics and Machine Learning Toolbox, mouse, cellular / molecular, 7 references
[10] doi:10.1126/sciadv.ade1249 [code]
Functional imaging and quantification of multineuronal olfactory responses in <i>C. elegans</i>.
Journal: Science advances
In common: Statistics and Machine Learning Toolbox, cellular / molecular, 7 references

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.

Add the badge to the code's README

OSCR: the badge of this paper

If you are an author or a maintainer of this paper's code, you can add this badge. It says what OSCR knows of the paper (its code, its validated tracing map) and links the code to this page. Paste one of these snippets under the README's title. Keep the two marks around it, so that the badge can be replaced in place and is never added twice. OSCR changes nothing in your repository.

Markdown
<!-- oscr-badge:start -->
[![OSCR](https://openscicode.org/badge/doi/10.1073/pnas.2524661123.svg)](https://openscicode.org/badge/doi/10.1073/pnas.2524661123)
<!-- oscr-badge:end -->
reStructuredText
.. oscr-badge:start

.. image:: https://openscicode.org/badge/doi/10.1073/pnas.2524661123.svg
   :target: https://openscicode.org/badge/doi/10.1073/pnas.2524661123
   :alt: OSCR

.. oscr-badge:end
HTML
<!-- oscr-badge:start -->
<a href="https://openscicode.org/badge/doi/10.1073/pnas.2524661123"><img src="https://openscicode.org/badge/doi/10.1073/pnas.2524661123.svg" alt="OSCR"></a>
<!-- oscr-badge:end -->

On GitHub, open the README, choose Edit (the pencil), paste the snippet under the title, then "Commit changes".

If you choose to, you can have the badge kept up to date for you, with oscr badge --write from the command line or with the GitHub Action in your repository's workflows. Every other form and option (a card, AsciiDoc, LaTeX, shields.io) is on the badge 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: code errors and mismatches between the code and the paper about this paper and its code, from readers and its authors, beside the paper and its tracing map.

Report a mismatch between the code and the paper · Report a code error (signed in)

Reproductions: reports from readers who ran the authors' code and did not get the paper's result, what they ran, with which environment, commit and data.

Report a reproduction failure (signed in)

Versions of its code: the releases of its code tied to a version of this paper (the preprint, the accepted manuscript, the version of record…), each with the tracing map versioned with it.

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