OSCR

A mechanistic theory of planning in prefrontal cortex.

Code ↔ Paper

12 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 12 matches
  1. [1] § Methods › Analyses › Decoding analyses ↔ pysta/subspace_utils.py, lines 81 to 200 · score 0.78 · logistic regression models, L2 regularised, regularisation strength, decoder, encoding, predicted · Report a mismatch
  2. [2] § Methods › The spacetime attractor › Network dynamics ↔ pysta/agents.py, lines 715 to 810 · score 0.78 · exponential decay, temperature parameter, neural potential, analytically, simplicity, nonlinearity · Report a mismatch
  3. [3] § Methods › Analyses › Decoding analyses ↔ pysta/analysis_utils.py, lines 84 to 188 · score 0.76 · logistic regression, predict location, neural activity, future location, inverse, crossvalidation · Report a mismatch
  4. [4] § Methods › Analyses › Fixed point analyses ↔ pysta/agents.py, lines 422 to 481 · score 0.67 · recurrent noise, network iterations, firing rate, network dynamics · Report a mismatch
  5. [5] § Methods › Analyses › Subspace identification ↔ scripts/analyse_changing_maze_rnn.py, lines 167 to 297 · score 0.63 · orthogonal subspaces, planning subspaces, working memory, activity, location · Report a mismatch
  6. [6] § Methods › Tasks ↔ pysta/envs.py, lines 13 to 100 · score 0.62 · wall configuration, reward landscape, reward function, execution, mazes, transitions · Report a mismatch
  7. [7] § Methods › Analyses › Estimating effective connectivity ↔ scripts/analyse_changing_maze_rnn.py, lines 99 to 152 · score 0.58 · point biserial correlations, adjacency matrices, weights, connectivity · Report a mismatch
  8. [8] § Methods › Analyses › Perturbation analyses ↔ figure_code/plot_attractor_dynamics.py, lines 107 to 126 · score 0.58 · raw firing rates, stimulation strength, dynamics, RNN · Report a mismatch
  9. [9] § Methods › The spacetime attractor › Practical considerations ↔ pysta/agents.py, lines 422 to 481 · score 0.55 · update neurons, network iterations, feedforward, agent, dynamics · Report a mismatch
  10. [10] § Methods › The spacetime attractor › Network dynamics ↔ pysta/agents.py, lines 635 to 680 · score 0.54 · exponential nonlinearity, firing rate, iteration, recurrent, network, dynamics · Report a mismatch
  11. [11] § Results › STAs are efficient planners › RNNs learn a world model ↔ figure_code/plot_rnn_connectivity.py, lines 152 to 206 · score 0.54 · recurrent weights, RNN learned, adjacency matrix, std, connections, subspaces · Report a mismatch
  12. [12] § Methods › Analyses › Decoding analyses ↔ pysta/analysis_utils.py, lines 84 to 188 · score 0.52 · predict location, neural activity, held, crossvalidation, decoders, behaviour · 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 · 1,020 lines · 42 KB · MIT · 4 matches

  1. import numpy as np
  2. import pickle
  3. import torch
  4. from torch import nn
  5. import torch.nn.functional as F
  6. import matplotlib.pyplot as plt
  7. #%% base agent
  8. class BaseAgent(nn.Module):
  9. classname = "BaseAgent"
  10. label = "base"
  11. def __init__(self, env, rec_noise = 1e-3, ent_reg = 1e-5, greedy = False, force_optimal = False, iters_per_action = 1, tau = 1, **kwargs):
  12. """
  13. RNN agent that learns to navigate in a maze
  14. Parameters
  15. ----------
  16. env : MazeEnv
  17. Environment that the agent is going to interact with
  18. rec_noise : float
  19. Noise added to the recurrent dynamics
  20. ent_reg: float
  21. Amount of entropy regularization to add
  22. greedy : bool
  23. If True, take greedy actions, otherwise sample from the policy
  24. force_optimal : bool
  25. Only allow optimal actions. If there are multiple actions, renormalize the policy over these.
  26. iters_per_action : int or list of ints
  27. Number of RNN iterations to run for each environment iteration.
  28. tau : float
  29. timescale of the dynamics. r_{t+1} = (1-1/tau) * r_t + (1/tau) * f(r_t, x_{t+1})
  30. """
  31. # pytorch boilerplate
  32. super(BaseAgent, self).__init__()
  33. # store some hyperparameters
  34. self.env = env
  35. self.greedy = greedy
  36. self.force_optimal = force_optimal
  37. self.iters_per_action = iters_per_action
  38. self.tau = tau
  39. self.rec_noise = rec_noise
  40. self.store_all_activity = False
  41. self.ent_reg = ent_reg
  42. # and get some from the environment
  43. self.Nout = self.env.output_dim # dimensionality of the policy we're learning
  44. self.Nin = self.env.obs_dim # dimensionality of the observations
  45. # instantiate parameters
  46. self.initialise_weights()
  47. # reset the model to its initial conditions
  48. self.reset()
  49. return
  50. def initialise_weights(self):
  51. """
  52. Instantiate the model parameters
  53. """
  54. raise NotImplementedError
  55. def allo_to_ego_pi(self, allo_pi):
  56. """
  57. Convert an allocentric policy to an egocentric policy by renormalizing over neighboring states
  58. In this version, each action is given the non-normalized probability associated with the resulting state (instead of distributing the probability mass)
  59. This is done to ensure that the greedy actions agree
  60. """
  61. ego_pi = torch.zeros(allo_pi.shape[0], self.env.num_actions) # new egocentric policy
  62. new_locs = self.env.neighbors[self.env.batch_inds, self.env.loc, :] # for each action, where do I end up
  63. for a in range(self.env.num_actions): # for each action
  64. new_locs_a = new_locs[:, a] # where would I end up
  65. ego_pi[:, a] = allo_pi[self.env.batch_inds, new_locs_a] # what is the probability of going here
  66. return ego_pi / ego_pi.sum(-1, keepdims = True)
  67. @property
  68. def name(self):
  69. """Generates a string representation of the agent"""
  70. if type(self.iters_per_action) in [int, np.int32, np.int64]:
  71. iter_str = f"iter{self.iters_per_action}"
  72. else:
  73. iter_str = f"iter{'-'.join([str(val) for val in self.iters_per_action])}"
  74. tau_str = f"tau{self.tau}"
  75. force_str = "opt" if self.force_optimal else "agent"
  76. return f"{self.classname}/{iter_str}_{tau_str}_{force_str}"
  77. def reset(self):
  78. """
  79. Reset the agent.
  80. This function sets the initial condition and empties caches storing data.
  81. """
  82. # no gradients for resetting environment
  83. with torch.no_grad():
  84. self.env.reset() # reset environment state
  85. # also initialise some lists to store data along the way
  86. self.store = [] # store many things at the time of each action
  87. self.all_acts = [[], [], []] # also store some information for dynamics in between actions
  88. # need gradients for setting initial z
  89. self.z = torch.zeros(torch.Size([self.env.batch])+self.z0.shape, device = self.z0.device) + self.z0[None, ...]
  90. self.r = self.phi(self.z)
  91. # instantiate loss functions
  92. self.acc_loss = torch.tensor(0.0) # accuracy
  93. self.weight_loss = self.env.batch * self.calc_parameter_reg() # parameter regularization
  94. self.rate_loss = self.calc_activity_reg() # rate regularization
  95. self.ent_loss = torch.tensor(0.0) # entropy regularization
  96. self.update_optimal_actions() # cache optimal actions
  97. return
  98. def sample_action(self):
  99. """
  100. This function samples an action for each trial in a batch from the current policy.
  101. Note that 'self.action' is the action chosen by an unconstrained agent.
  102. 'self.env_action' is the action passed to the environment and can be different if we enforce optimality.
  103. We distinguish between the two because e.g. accuracies are still computed from the sampled action.
  104. Returns
  105. ----------
  106. action : tensor
  107. action taken for each trial in the batch
  108. """
  109. with torch.no_grad():
  110. if self.env.output_format == "allocentric":
  111. adj = self.env.adjacency[self.env.batch_inds, self.env.loc, :] # renormalize over adjacent states
  112. assert adj.sum(-1).min() >= 2 # we should always have adjacent states
  113. pi = (self.pi+1e-20) * adj # add some jitter to make sure the policy is not exactly zero. Otherwise we sometimes run into nans
  114. pi = pi/pi.sum(-1, keepdims = True) # normalise
  115. else:
  116. pi = self.pi # if we're already in egocentric space, everything is possible
  117. if self.greedy: # pick the most likely action
  118. self.action = torch.argmax(pi, -1)
  119. else: # sample an action
  120. try:
  121. self.action = torch.multinomial(pi, 1)[..., 0]
  122. except RuntimeError:
  123. # something went wrong, so we save the current state of the agent for debugging.
  124. # This may happen if the policy becomes near-deterministic, and it can often be resolved by increasing the entropy regularisation.
  125. print(pi.min(), pi.max(), self.pi.min(), self.pi.max(), self.logpi.min(), self.logpi.max())
  126. pickle.dump(self, open("./temp.p", "wb"))
  127. raise Error
  128. if self.force_optimal: # optionally renormalize over optimal actions first
  129. # add small epsilon in case there are no optimal actions -> uniform policy (e.g. if the goal is too far away to be reached within the trial)
  130. opt_pis = pi * self.optimal_actions + torch.rand(pi.shape)*1e-20
  131. if self.greedy: # pick most likely action
  132. self.env_action = torch.argmax(opt_pis, -1)
  133. else: # sample an action
  134. self.env_action = torch.multinomial(opt_pis, 1)[..., 0]
  135. else: # if we're not enforcing optimality, the action passed to environment is just the original action
  136. self.env_action = self.action
  137. return self.action
  138. def step(self, observation):
  139. """
  140. Perform all the computations happening before the next environment update step
  141. This can include multiple iterations of recurrent network dynamics.
  142. Parameters
  143. ----------
  144. observation : tensor
  145. The observation at this point in time
  146. Returns
  147. ----------
  148. action : tensor
  149. action taken for each trial in the batch
  150. """
  151. raise NotImplementedError
  152. def calc_activity_reg(self, not_finished = None):
  153. """
  154. Compute firing rate regularization loss. 0 by default.
  155. """
  156. return 0.0
  157. def calc_parameter_reg(self):
  158. """
  159. Compute weight regularization loss. 0 by default.
  160. """
  161. return 0.0
  162. def update_loss(self):
  163. """
  164. Accumulate losses.
  165. Treat accuracy loss, rate loss, and parameter loss separately.
  166. Losses are only added for trials that have not finished.
  167. """
  168. not_finished = torch.where(~self.env.finished)[0] # trials that have not finished
  169. if self.env.step_num >= 0: # only apply prediction and parameter loss during execution phase
  170. # policy loss (sum_{a \in opt_as} pi(a))
  171. opt_probs = (self.pi*self.optimal_actions)[not_finished, :].sum(-1)
  172. assert opt_probs.max() < 1.0 + 1e-5 # check that things are not too crazy
  173. self.acc_loss = self.acc_loss + (1.0 - opt_probs).sum() # turn our objective into a loss and sum across batches
  174. # entropy loss
  175. jitter = 1e-5 # add a little bit of jitter to avoid nans
  176. pi_ent = (self.pi + jitter) / (1+self.pi.shape[-1] * jitter) # make sure things are normalised
  177. self.ent_loss = self.ent_loss + self.ent_reg * (pi_ent*pi_ent.log())[not_finished, :].sum() # want to maximize entropy; minimize -H = E[pi logpi]
  178. return
  179. def update_store(self):
  180. """
  181. Update list of environment/agent states to include the current state.
  182. """
  183. corrects = self.optimal_actions[self.env.batch_inds, self.action] # was the action correct for each trial?
  184. corrects[self.env.finished] = torch.nan # nans if trial is finished
  185. self.store.append({
  186. "rs": self.r.detach(), # firing rate
  187. "zs": self.z.detach(), # neural potential
  188. "loc": self.env.loc, # location
  189. "action": self.action, # action generated by the agent
  190. "env_action": self.env_action, # action passed to the environment
  191. "optimal_actions": self.optimal_actions, # optimal action
  192. "step_num": self.env.step_num, # how far along are we in the trial (negative during the planning phase)
  193. "finished": self.env.finished.clone(), # which trials have finished
  194. "pi": self.pi.detach(), # policy
  195. "xs": self.env.observation(), # inputs
  196. "corrects" : corrects # whether actions are correct
  197. })
  198. def update_optimal_actions(self):
  199. """
  200. Cache optimal actions
  201. """
  202. with torch.no_grad():
  203. self.optimal_actions = self.env.optimal_actions() # tensor (batch, output_dim)
  204. def forward(self, store = False):
  205. """
  206. Run a single batch of trials
  207. Parameters
  208. ----------
  209. store : bool
  210. if true, store environment and agent states after every action
  211. Returns
  212. ----------
  213. avg_loss : tensor
  214. average loss across trials in a batch
  215. """
  216. self.reset() # reset agent
  217. while not torch.all(self.env.finished): # as long as there are some trials left to act in
  218. self.update_optimal_actions() # cache the optimal actions at the current location
  219. x = self.env.observation().to(self.z0.device) # observation at this point in time
  220. self.step(x) # update RNN state, compute policy, and sample an action
  221. self.update_loss() # update performance and entropy loss
  222. # now update environment and optionally store env+agent state (don't propagate gradients through this)
  223. with torch.no_grad():
  224. if store:
  225. self.update_store()
  226. self.env.step(self.env_action) # action passed to the environment (optionally restricted to be optimal)
  227. # loss is combined accuracy and regularization losses, normalized by the batch size
  228. return (self.acc_loss + self.weight_loss + self.rate_loss + self.ent_loss) / self.env.batch
  229. def eval(self, num_eval = 5):
  230. """
  231. Compute average loss and accuracy across a number of trials
  232. Parameters
  233. ----------
  234. num_eval : int
  235. number of batches to average over
  236. Returns
  237. ----------
  238. avg_loss : float
  239. average loss across trials in all batches
  240. avg_acc : float
  241. average accuracy across trials in all batches
  242. """
  243. losses, accs = [], [] # lists to concatenate across batches
  244. for _ in range(num_eval): # for each batch
  245. # simulate the trials
  246. loss = self.forward(store = True).detach().cpu().numpy()
  247. losses.append(loss) # append loss
  248. corrects = torch.stack([s["corrects"] for s in self.store if s["step_num"]>=0]) # accuracy for each trial and step in the execution phase
  249. accs.append(torch.nanmean(corrects, axis = 0).mean()) # average
  250. return np.mean(losses), np.mean(accs)
  251. def plot_trial(self, filename = None, run_trial = True, trial_num = 0, values = True, vmap = None, cmap = "coolwarm"):
  252. """
  253. Plot a summary plot for a trial
  254. Parameters
  255. ----------
  256. filename : str
  257. filename to save to
  258. run_trial : bool
  259. whether to run a new trial (True) or use cached information (False)
  260. trial_num : int
  261. which trial number within the batch to plot
  262. values : bool
  263. for the reward landscape task, whether to plot a value map (True) or reward map (False)
  264. vmap : tensor
  265. optional data to plot as a heatmap for each time point
  266. cmap : str
  267. colormap to use
  268. """
  269. if run_trial: # optionally simulate a new trial
  270. self.forward(store = True)
  271. # find out which time points have not finished
  272. fs = 1 - np.array([store["finished"][trial_num].numpy() for store in self.store])
  273. T = min(np.sum(fs)+1, len(self.store)) # number of time points to plot
  274. fig, axs = plt.subplots(1, T, figsize = (2*T, 2))
  275. for t, ax in enumerate(axs): # for each time point
  276. data = self.store[t] # get data for this time point
  277. # plot a panel
  278. vmap_t = None if vmap is None else vmap[t] # optionally plot some useful data
  279. self.env.plot(loc = data["loc"][trial_num], step_num = data["step_num"], ax = ax, trial_num = trial_num, values = values, cmap = cmap, vmap = vmap_t)
  280. plt.tight_layout = True
  281. if filename is not None:
  282. plt.savefig(filename, bbox_inches = "tight")
  283. plt.close()
  284. return
  285. #%% Vanilla RNN
  286. class VanillaRNN(BaseAgent):
  287. classname = "VanillaRNN"
  288. label = "rnn"
  289. def __init__(self, env, Nrec = 800, W_reg = 1e-3, r_reg = 1e-3, nonlin_output = False, **kwargs):
  290. """
  291. RNN agent that learns to navigate in a maze
  292. Parameters
  293. ----------
  294. env : MazeEnv
  295. Environment that the agent is going to interact with
  296. Nrec : int
  297. Number of relu units in the RNN's hidden layer
  298. W_reg : float
  299. Regularization strength for the parameters
  300. r_reg : float
  301. Regularization strength for the hidden layer activity
  302. nonlin_output : bool
  303. If True, have an additional hidden layer in the readout. Otherwise use a linear readout from the hidden state.
  304. """
  305. # store some model-specific parameters
  306. self.Nrec = Nrec
  307. self.nonlin_output = nonlin_output
  308. self.W_reg, self.r_reg = W_reg, r_reg
  309. self.phi = F.relu # use a ReLU nonlinearity
  310. # initialise BaseAgent
  311. super(VanillaRNN, self).__init__(env, **kwargs)
  312. @property
  313. def name(self):
  314. """generates a string representation of the agent"""
  315. nonlin_str = "nonlinout" if self.nonlin_output else "linout"
  316. basename = super(VanillaRNN, self).name
  317. return f"{basename}/N{self.Nrec}_{nonlin_str}"
  318. def initialise_weights(self):
  319. """
  320. Instantiate the learnable model parameters.
  321. We just initialise the parameters as iid Gaussian variables.
  322. """
  323. # RNN initial condition
  324. self.z0 = nn.Parameter(torch.randn(self.Nrec, 1), requires_grad=True)
  325. # recurrent weight matrix
  326. self.Wrec = nn.Parameter(torch.randn(self.Nrec, self.Nrec) / np.sqrt(self.Nrec), requires_grad=True)
  327. # input weight matrix
  328. self.Win = nn.Parameter(torch.randn(self.Nrec, self.Nin) / np.sqrt(self.Nin), requires_grad=True)
  329. # hidden state bias
  330. self.brec = nn.Parameter(torch.zeros(self.Nrec, 1), requires_grad=True)
  331. # now initialise the output function, which can be either nonlinear or linear
  332. if self.nonlin_output:
  333. # create one hidden layer between the RNN and policy
  334. # weights and bias to hidden output layer
  335. self.Wout1 = nn.Parameter(torch.randn(int(np.round(self.Nrec/2)), self.Nrec) / np.sqrt(self.Nrec), requires_grad=True)
  336. self.bout1 = nn.Parameter(torch.zeros(self.Wout1.shape[0], 1), requires_grad=True)
  337. # weights and bias to policy
  338. self.Wout = nn.Parameter(torch.randn(self.Nout, self.Wout1.shape[0]) / np.sqrt(self.Wout1.shape[0]), requires_grad=True)
  339. self.bout = nn.Parameter(torch.zeros(self.Nout, 1), requires_grad=True)
  340. else:
  341. # just a single weight and bias for a linear readout to out policy
  342. self.Wout = nn.Parameter(torch.randn(self.Nout, self.Nrec) / np.sqrt(self.Nrec), requires_grad=True)
  343. self.bout = nn.Parameter(torch.zeros(self.Nout, 1), requires_grad=True)
  344. return
  345. def step(self, observation):
  346. """
  347. Perform all the computations happening before the next environment update step
  348. This can include multiple iterations of recurrent network dynamics.
  349. Parameters
  350. ----------
  351. observation : tensor
  352. The observation at this point in time
  353. Returns
  354. ----------
  355. action : tensor
  356. action taken for each trial in the batch
  357. """
  358. batch = observation.shape[0] # batch size
  359. # decide how many network iterations to run for this environment iteration
  360. network_iters = self.iters_per_action if type(self.iters_per_action) in [int, np.int32, np.int64] else np.random.choice(self.iters_per_action)
  361. for _ in range(network_iters): # optionally several network iterations
  362. # recurrent noise
  363. rec_noise = torch.randn(batch, self.Nrec, 1, device = self.z0.device) * self.rec_noise
  364. # feedforward input
  365. ff_inp = self.Win @ observation[..., None]
  366. # recurrent input
  367. rec_inp = self.Wrec @ self.r
  368. # update neuron potentials
  369. self.z = (1 - 1/self.tau) * self.z + (1/self.tau)*(rec_inp + ff_inp + self.brec + rec_noise)
  370. # compute firing rates
  371. self.r = self.phi(self.z) # firing rate
  372. # update rate loss for trials that have not finished
  373. self.rate_loss = self.rate_loss + self.calc_activity_reg(torch.where(~self.env.finished)[0])
  374. if self.store_all_activity: # optinoally store activity at every RNN iteration
  375. self.all_acts[0].append(self.r.detach().numpy())
  376. self.all_acts[1].append( self.env.loc.detach().numpy())
  377. self.all_acts[2].append(self.env.step_num)
  378. # compute output
  379. if self.nonlin_output:
  380. rout = self.phi(self.Wout1 @ self.r + self.bout1) # pass through hidden layer
  381. else:
  382. rout = self.r # just linear readout
  383. self.logpi = (self.Wout @ rout + self.bout)[..., 0] # flatten for each batch (batch x actions x 1) -> (batch, actions)
  384. # normalize log policy
  385. self.logpi = self.logpi - self.logpi.logsumexp(-1, keepdims = True)
  386. self.pi = self.logpi.exp() # compute policy (batch, actions)
  387. # sample an action from the policy
  388. self.action = self.sample_action()
  389. return self.action
  390. def calc_activity_reg(self, not_finished = None):
  391. """
  392. Compute firing rate regularization loss
  393. Parameters
  394. ----------
  395. not_finished : bool
  396. trials within the batch which have not yet finished.
  397. if None: assume no trials are finished.
  398. Returns
  399. ----------
  400. reg_loss : tensor
  401. firing rate regularization loss. Summed across trials and neurons
  402. """
  403. if not_finished is None:
  404. reg_loss = self.r_reg * (self.r**2).sum() # magnitude of hidden state vector
  405. else:
  406. reg_loss = self.r_reg * (self.r[not_finished]**2).sum() # magnitude of hidden state vector
  407. return reg_loss
  408. def calc_parameter_reg(self):
  409. """
  410. Compute weight regularization loss
  411. Returns
  412. ----------
  413. reg_loss : tensor
  414. weight regularization loss. sum across all parameters.
  415. """
  416. reg_loss = self.W_reg * torch.stack([torch.square(p).sum() for p in self.parameters()]).sum() # magnitude of total weight vecto
  417. return reg_loss
  418. #%% Handcrafted spacetime attractor
  419. class SpaceTimeAttractor(BaseAgent):
  420. classname = "SpaceTimeAttractor"
  421. label = "sta"
  422. def __init__(self, env, beta = 9.0, tau = 50, iters_per_action = 400, shift_time = 2.0, rec_noise = 1e-1, adj_noise = 1e-2, **kwargs):
  423. """
  424. Spacetime attractor that optimises a reward function
  425. Parameters
  426. ----------
  427. env : MazeEnv
  428. Environment that the agent is going to interact with
  429. beta : Float
  430. Temperature parameter for the reward function. Performance is somewhat sensitive to this.
  431. If beta is too low, the network can converge to a diffuse representation instead of a clean trajectory to the goal.
  432. If beta is too high, the representation can 'teleport' for long trajectories.
  433. tau : Float
  434. Time constant for the dynamics
  435. shift_time : Float
  436. Number of time constants for which a feedforward component is included in the dynamics.
  437. rec_noise : Float
  438. Magnitude of the noise added to the recurrent dynamics.
  439. adj_noise : Float
  440. Magnitude of the noise added to each element of the recurrent weights
  441. """
  442. # set some hyperparameters
  443. self.num_modules = env.max_steps+1 # Number of subspaces; current loc plus max_steps future locs
  444. self.num_locs = env.num_locs
  445. self.batch = env.batch
  446. self.adj_noise = adj_noise
  447. self.shift_time = shift_time
  448. self.Nrec = self.num_modules * self.num_locs # total number of neurons across modules
  449. self.beta = beta # reward function temperature parameters
  450. self.exact = False # perform exact inference
  451. # initialise BaseAgent
  452. kwargs["tau"] = tau
  453. kwargs["iters_per_action"] = iters_per_action
  454. super(SpaceTimeAttractor, self).__init__(env, **kwargs)
  455. self.rec_noise = rec_noise # recurrent noise to ensure robustness
  456. def phi(self, x):
  457. """
  458. We use an exponential nonlinearity.
  459. """
  460. return x.exp()
  461. def initialise_weights(self):
  462. """
  463. Instantiate the handcrafted model parameters
  464. """
  465. A = self.env.adjacency.clone() # adjacency matrix of the environment
  466. adj_noise = self.adj_noise # how much noise will we add
  467. bias = -5e-3 # bias added to the noise
  468. # instantiate recurrent weight matrices
  469. self.Wrec_fwd, self.Wrec_bwd = [torch.zeros(self.batch, self.Nrec, self.Nrec) for _ in range(2)]
  470. self.Win = torch.zeros(self.Nrec, self.Nin) # input weights
  471. self.Wout = torch.zeros(self.env.num_locs, self.Nrec) # inherently allocentric; can convert to egocentric subsequently
  472. self.Wrec_shift = torch.zeros(self.Nrec, self.Nrec) # weights for the 'shift' dynamics
  473. # set recurrent weights
  474. for mod1 in range(self.num_modules-1): # for each subspace
  475. # indices of the weight matrices corresponding to this subspace and the next
  476. mod1_inds, mod2_inds = [torch.arange(self.num_locs)+self.num_locs*ind for ind in [mod1, mod1+1]]
  477. self.Wrec_shift[mod1_inds, mod2_inds] = torch.ones(self.num_locs) # shift connections from delta+1 to delta
  478. for i1, ind1 in enumerate(mod1_inds):
  479. for i2, ind2 in enumerate(mod2_inds):
  480. # weights are just the adjacency matrix plus some noise
  481. self.Wrec_fwd[:, ind2, ind1] = A[:, i2, i1].clone() + (torch.rand(self.batch) - 2.0)*adj_noise + bias
  482. self.Wrec_bwd[:, ind1, ind2] = A[:, i1, i2].clone() + (torch.rand(self.batch) - 2.0)*adj_noise + bias
  483. # total recurrent weights combine forward and backward connections between subspaces
  484. self.Wrec = self.Wrec_fwd + self.Wrec_bwd
  485. # input weights
  486. self.Win[:self.num_locs, :self.num_locs] = torch.eye(self.num_locs) # current loc
  487. self.Win[self.num_locs:, 2*self.num_locs:-2*self.num_locs] = torch.eye(self.Win.shape[0] - self.num_locs) # future reward (zero for _current_ reward)
  488. # output is just the delta=1 subspace
  489. self.Wout[:, self.num_locs:2*self.num_locs] = torch.eye(self.num_locs)
  490. # initial condition is uniform in space at each time
  491. self.z0 = torch.log(torch.ones(self.num_modules, self.num_locs) / self.num_locs)
  492. #bias is zero for this agent
  493. self.brec = torch.zeros(self.Nrec, 1)
  494. return
  495. def calc_policy(self):
  496. """compute policy from firing rate"""
  497. self.pi = (self.Wout @ self.r.reshape(self.batch, -1, 1))[..., 0] # policy ends up being activity in second module
  498. if self.env.output_format == "egocentric": # optionally convert to an egocentric policy
  499. self.pi = self.allo_to_ego_pi(self.pi)
  500. def obs_to_rew_func(self, observation, flat = False):
  501. """This function extract the location and reward information from the inputs and puts it into the right format"""
  502. obs_mod = observation.reshape(self.batch, -1, self.num_locs) # batch by input type by location within module
  503. rew_inds = torch.cat([torch.arange(1), torch.arange(2, obs_mod.shape[1]-2)]) # indicies of the inputs corresponding to current location and future reward
  504. logrews = obs_mod[:, rew_inds, :] # reward function (batch, modules, locs)
  505. logrews[:, 0, :] *= 20.0 # strong signal to start at current location
  506. logrews *= self.beta # multiply by temperature parameter
  507. logrews -= logrews.logsumexp(axis = -1, keepdims = True) # turn into a distribution over locations and add final vector dimension
  508. if flat: # flatten back into a vector instead of a matrix
  509. obs_mod[:, rew_inds, :] = logrews # replace reward function in observation
  510. return obs_mod.reshape(self.batch, -1, 1)
  511. else:
  512. return logrews[..., None]
  513. def step(self, observation):
  514. clip_minval = torch.tensor(-100) # threshold value for inhbition
  515. # first extract reward function from observation
  516. logrews = self.obs_to_rew_func(observation.clone(), flat = True) # flatten for each trial (batch, modules*locs)
  517. # select how many network iterations to run
  518. network_iters = self.iters_per_action if type(self.iters_per_action) in [int, np.int32, np.int64] else np.random.choice(self.iters_per_action)
  519. for iter_ in range(network_iters): # for each iteration
  520. rs = self.r.reshape(self.batch, -1, 1) # firing rate but add vector dimension
  521. # compute change in activity
  522. dzdt = (self.Win @ logrews + torch.maximum(clip_minval.exp(), self.Wrec_fwd @ rs).log() + torch.maximum(clip_minval.exp(), self.Wrec_bwd @ rs).log())
  523. # optionally add a 'shift component' to the dynamics for a period after each action
  524. if (iter_ < self.tau*self.shift_time) and (self.env.step_num > 0):
  525. dzdt += torch.maximum(clip_minval.exp(), self.Wrec_shift @ rs).log()
  526. # reshape update and bias
  527. dzdt = dzdt.reshape(self.batch, -1, self.num_locs)
  528. bias = self.brec.reshape(1, -1, self.num_locs)
  529. # sample recurrent noise
  530. rec_noise = torch.randn(dzdt.shape, device = self.z0.device) * self.rec_noise
  531. # update activity
  532. self.z = (1-1/self.tau) * self.z + 1/self.tau * (dzdt + rec_noise + bias)
  533. # normalize and threshold
  534. self.z = self.z - self.z.logsumexp(axis = -1, keepdims = True) # normalise
  535. self.z = torch.clip(self.z, min = clip_minval, max = 0.0) # threshold
  536. self.r = self.phi(self.z) # apply exponential nonlinearity to compute firing rates
  537. if self.store_all_activity: # optionally store activity at every RNN iteration
  538. self.all_acts[0].append(self.r.detach().numpy())
  539. self.all_acts[1].append( self.env.loc.detach().numpy())
  540. self.all_acts[2].append(self.env.step_num)
  541. #compute the policy
  542. self.calc_policy()
  543. # sample an action from the policy
  544. self.action = self.sample_action()
  545. return self.action
  546. def reset(self):
  547. """Reset the agent. This involves the BaseAgent reset and then re-initialising weights in case the environment changed"""
  548. super(SpaceTimeAttractor, self).reset()
  549. self.initialise_weights() # reinitialize weights
  550. return
  551. def plot_representation(self, filename = None, trial_num = 0, **kwargs):
  552. """
  553. Generate a summaruy plot for a trial
  554. Parameters
  555. ----------
  556. filename : str
  557. filename to save to
  558. trial_num : int
  559. which trial number within the batch to plot
  560. """
  561. # find out which time points have not finished
  562. fig, axs = plt.subplots(1, self.num_modules, figsize = (2*self.num_modules, 2))
  563. for t, ax in enumerate(axs): # for each time point, plot a panel
  564. loc = self.env.loc[trial_num] if t == 0 else torch.argmax(self.r[trial_num, t]) # predicted location at this time
  565. self.env.plot(loc = loc, vmap = self.r[trial_num, t, :], step_num = t, ax = ax, trial_num = trial_num,
  566. cmap = "YlOrRd", plot_optimal_actions = t<len(axs)-1, vmin = -0.1, vmax = 1.2, **kwargs)
  567. plt.tight_layout = True
  568. if filename is not None:
  569. plt.savefig(filename, bbox_inches = "tight")
  570. plt.close()
  571. return
  572. #%% Successor representation agent
  573. class SRLearner(BaseAgent):
  574. classname = "SRLearner"
  575. label = "sr"
  576. def __init__(self, env, gamma = 0.95, beta = 5.0, **kwargs):
  577. """
  578. Successor representation agent that navigates in a maze
  579. Parameters
  580. ----------
  581. env : MazeEnv
  582. Environment that the agent is going to interact with
  583. gamma : Float
  584. The discount factor used to compute the successor matrix
  585. beta : Float
  586. The temperature parameter used to compute the policy
  587. """
  588. # instantiate class and save some parameters
  589. self.gamma = gamma
  590. self.beta = beta # temperature parameter
  591. super(SRLearner, self).__init__(env, **kwargs)
  592. self.Nrec = 2*self.env.num_locs # expected occupancy of each location and expected value of each location
  593. return
  594. def phi(self, x):
  595. return x # no nonlinearity
  596. def reset(self):
  597. """Reset Baseagent then recompute successor matrix"""
  598. super(SRLearner, self).reset()
  599. adj = self.env.adjacency # (batch, to, from)
  600. T = adj / adj.sum(1, keepdims = True) # transition matrix
  601. self.M = torch.linalg.inv(torch.eye(T.shape[-1])[None, ...] - self.gamma*T) # analytically compute successor matrix
  602. self.r = None # no neural activity
  603. return
  604. def initialise_weights(self):
  605. """
  606. Model parameters are empty; we will compute everything analytically
  607. """
  608. self.z0 = torch.zeros(self.env.num_locs) # z is value, which we initialize to zero
  609. return
  610. def step(self, observation):
  611. """
  612. Perform one 'update step'
  613. For simplicity, we just multiply the average occupancy with the average reward function.
  614. If the reward format is 'relative', this ends up being the 'reward-to-go'.
  615. In the future, we could think about also using the 'occupancy-to-go' instead of just exponentially decayed occupancy.
  616. Parameters
  617. ----------
  618. observation : tensor
  619. The observation at this point in time
  620. Returns
  621. ----------
  622. action : tensor
  623. action taken for each trial in the batch
  624. """
  625. rew_func = observation.reshape(self.env.batch, -1, self.env.num_locs)[:, 1:-2, :].clone() # reward function (batch, steps, locs)
  626. # compute average reward
  627. mean_rew = rew_func[:, 1:, :].mean(1, keepdims = True) # ignore reward at current time
  628. # compute value function
  629. self.values = (mean_rew @ self.M)[:, 0, :] # (batch, locs)
  630. self.z = self.values # neural potential is just the value function
  631. exp_occ = self.M[self.env.batch_inds, :, self.env.loc] # expected occupancy
  632. # 'neural activity' is values and occupancy. This is not used for any computation, but may be interesting for decoding analyses.
  633. self.r = torch.cat([self.values, exp_occ], 1)[..., None]
  634. # compute policy
  635. self.pi = (self.beta * self.values).exp() # allocentric policy
  636. self.pi /= self.pi.sum(-1, keepdims = True) # normalize
  637. if self.env.output_format == "egocentric":
  638. self.pi = self.allo_to_ego_pi(self.pi) # optionally convert to egocentric policy
  639. self.action = self.sample_action() # sample an action from the policy
  640. return self.action
  641. def plot_trial_values(self, filename = None, run_trial = True, trial_num = 0, cmap = "coolwarm"):
  642. """Function for plotting the value function for a trial"""
  643. if run_trial: # optionally simulate a new trial
  644. self.forward(store = True)
  645. vmap = [s["zs"][trial_num].flatten() for s in self.store]
  646. self.plot_trial(filename = filename, run_trial = False, trial_num = trial_num, vmap = vmap, cmap = cmap)
  647. #%% TD learning agent
  648. class TDLearner(BaseAgent):
  649. classname = "TDLearner"
  650. label = "td"
  651. def __init__(self, env, beta = 5.0, tau = 20, **kwargs):
  652. """
  653. Temporal difference learner that navigates in a maze
  654. Parameters
  655. ----------
  656. env : MazeEnv
  657. Environment that the agent is going to interact with
  658. beta : Float
  659. The temperature parameter used to compute a policy
  660. tau : Float
  661. The inverse of the learning rate
  662. """
  663. # instantiate super class
  664. self.beta = beta # temperature parameter
  665. kwargs["tau"] = tau
  666. super(TDLearner, self).__init__(env, **kwargs)
  667. return
  668. def phi(self, x):
  669. return x # no nonlinearity
  670. def reset(self):
  671. """Reset BaseAgent and store initial location"""
  672. super(TDLearner, self).reset()
  673. self.prev_loc = self.env.loc
  674. self.prev_not_finished = torch.ones(self.env.num_locs)
  675. return
  676. def initialise_weights(self):
  677. """
  678. Model parameters are the values
  679. """
  680. self.values = torch.zeros(self.env.num_locs)+1.0 # optimistic value initialisation
  681. self.z0 = self.values.clone() # our firing rates will also be the values
  682. return
  683. def run_td_update(self):
  684. """Run a TD update on the value function"""
  685. new_rew = self.env.latest_rew # the reward we just got
  686. prev_V = self.values[self.prev_loc] # value of previous location
  687. new_V = self.values[self.env.loc] * (~self.env.finished).to(float) # value of new location (no more value for finished trials)
  688. self.td_error = (new_rew + new_V - prev_V) * self.prev_not_finished # TD error (set to zero for finished episodes)
  689. # previous locations
  690. prev_locs_1hot = F.one_hot(self.prev_loc, num_classes = self.env.num_locs) # (batch, num_locs)
  691. # batched TD update
  692. updates = (prev_locs_1hot * self.td_error[:, None]).sum(0) / self.prev_not_finished.sum() # avg over all td errors for each location in the batch
  693. self.values += (1/self.tau)*updates # update values
  694. def step(self, observation):
  695. """
  696. Perform one 'update step'
  697. This involves both choosing an action and updating our value function
  698. Parameters
  699. ----------
  700. observation : tensor
  701. The observation at this point in time
  702. Returns
  703. ----------
  704. action : tensor
  705. action taken for each trial in the batch
  706. """
  707. # first select an action
  708. self.pi = (self.beta * self.values).exp()[None, ...] + torch.zeros(observation.shape[0], self.env.num_locs) # # allocentric policy is proportional to exponential value
  709. self.pi /= self.pi.sum(-1, keepdims = True) # normalize
  710. if self.env.output_format == "egocentric":
  711. self.pi = self.allo_to_ego_pi(self.pi) # optionally convert to egocentric
  712. self.action = self.sample_action() # sample an action from the policy
  713. # now update our values based on previous experience
  714. if self.env.step_num >= 1: # can only run a TD update once we start collecting experience
  715. self.run_td_update()
  716. self.z = self.values[None, :] + torch.zeros(self.env.batch, self.env.num_locs) # potential is just value
  717. self.r = self.phi(self.z) # firing rate is just value (this is not used for computation, just for visualisation)
  718. self.prev_loc = self.env.loc # save previous location
  719. self.prev_not_finished = (~self.env.finished).to(float) # which trials are done?
  720. return self.action
  721. def forward(self, store = False):
  722. """
  723. Run a single batch of trials
  724. Parameters
  725. ----------
  726. store : bool
  727. if true, store environment and agent states after every action
  728. Returns
  729. ----------
  730. avg_loss : tensor
  731. average loss across trials in a batch
  732. """
  733. loss = super(TDLearner, self).forward(store = store) # run default forward pass
  734. self.run_td_update() # important to update values based on final sample as well
  735. return loss
  736. def plot_trial_values(self, filename = None, run_trial = True, trial_num = 0, cmap = "YlOrRd"):
  737. """Function for plotting value function after a batch of trials"""
  738. if run_trial: # optionally simulate a new trial
  739. self.forward(store = True)
  740. vmap = [s["zs"][trial_num].flatten() for s in self.store]
  741. self.plot_trial(filename = filename, run_trial = False, trial_num = trial_num, vmap = vmap, cmap = cmap)
  742. #%% Dynamic programming agent (value agent in space and time)
  743. class DPAgent(BaseAgent):
  744. classname = "DPAgent"
  745. label = "dp"
  746. def __init__(self, env, beta = 5.0, **kwargs):
  747. """
  748. Dynamic programming agent that just computes an optimal spacetime value function once and for all
  749. Parameters
  750. ----------
  751. env : MazeEnv
  752. Environment that the agent is going to interact with
  753. beta : Float
  754. Temperature parameter for computing a policy
  755. """
  756. # store parameters
  757. self.beta = beta
  758. self.Nrec = env.num_locs + env.max_steps + np.prod(env.vs[0].shape) # location, time-in-trial, and spacetime value function
  759. # initialise BaseAgent
  760. super(DPAgent, self).__init__(env, **kwargs)
  761. return
  762. def phi(self, x):
  763. return x # no nonlinearity
  764. def initialise_weights(self):
  765. self.z0 = torch.zeros(self.Nrec, 1) # no parameters
  766. def update_z_and_r(self):
  767. """This just involves concatenating the relevant environment variables into a vector that will be used for decoding analyses"""
  768. self.t = torch.tensor(max(0, self.env.step_num)) # current time (everything during planning is called '0')
  769. self.flat_t = F.one_hot(self.t, num_classes = self.env.max_steps)+torch.zeros(self.env.batch, self.env.max_steps) # one-hot version
  770. self.loc = F.one_hot(self.env.loc, num_classes = self.env.num_locs) # current location as a one-hot
  771. self.values = self.env.vs # value function
  772. # set potential and firing rate to the concatenation of time, location, and values
  773. self.z = torch.cat([self.values.reshape(self.env.batch, -1), self.loc, self.flat_t], dim = -1)[..., None]
  774. self.r = self.z.clone()
  775. def reset(self):
  776. """Run BaseAgent reset step, and then re-cache the value function and recompute representation since the environment may have changed"""
  777. super(DPAgent, self).reset()
  778. self.values = self.env.vs
  779. self.update_z_and_r()
  780. return
  781. def step(self, observation):
  782. """
  783. Perform one 'update step'
  784. This involves updating the neural representation and sampling an action
  785. Parameters
  786. ----------
  787. observation : tensor
  788. The observation at this point in time
  789. Returns
  790. ----------
  791. action : tensor
  792. action taken for each trial in the batch
  793. """
  794. # first update representations
  795. self.update_z_and_r()
  796. # then compute policy
  797. next_values = self.values[:, self.t+1, :] # values at every upcoming point in spacetime
  798. self.pi = (self.beta * next_values).exp() # allocentric policy is proportional to exponential value
  799. self.pi /= self.pi.sum(-1, keepdims = True) # normalize
  800. if self.env.output_format == "egocentric":
  801. self.pi = self.allo_to_ego_pi(self.pi) # optionally convert to egocentric
  802. # and sample an action
  803. self.action = self.sample_action() # sample an action from the policy
  804. return self.action

agents.py at commit fdd6dd3, under MIT · at the source

Overview

  1. Sainsbury Wellcome Centre, University College London London United Kingdom
  2. Oxford Centre for Integrative Neuroimaging, University of Oxford Oxford United Kingdom
  3. Department of Experimental Psychology, University of Oxford Oxford United Kingdom
  4. Gatsby Computational Neuroscience Unit, University College London London United Kingdom
Institutions: University College London (United Kingdom); University of Oxford (United Kingdom)
Journal: eLife, volume 15, article RP109757
Dates: published online 21 October 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.7554/elife.109757 · PMID 42904774 · PMCID PMC13686619 · OpenAlex W7147688526
Open access: gold, a free copy (OpenAlex)
Status: code verified (how the code is found and verified)
Categories: computational modeling (no new data) (modality), human (organism)
Methods: Connectivity, Smoothing, state filtering, decompositions, Machine learning, Single-unit activity, calcium imaging
Keywords: planning, prefrontal cortex, recurrent neural networks, attractor dynamics, None
MeSH: Models, Neurological*, Nerve Net*, Prefrontal Cortex*, Animals, Humans, Neural Networks, Computer, Neurons, Recurrent Neural Networks (* major topic)
Journal subjects: Neuroscience
Topic: Neural dynamics and brain function (Cognitive Neuroscience, Neuroscience), according to OpenAlex
Funding: Wellcome Trust (10.35802/219525, 10.35802/225926, Sainsbury Wellcome Centre's core 219627/Z/19/Z); Gatsby Charitable Foundation (Sainsbury Wellcome Centre's core GAT3755, The Gatsby Initiative for Brain Development and Psychiatry GAT3955, GAT4058); Jean Francois and Marie-Laure de Clermont Tonerre Foundation; Oxford Clarendon Fund; Human Frontier Science Program (10.52044/HFSP.LT00402024-L.pc.gr.195051); European Molecular Biology Organization (ALTF 651-2023); Fondation Fyssen; Simons Foundation (SCGB543039); Royal Society (FDF\S2\251056)
Citations: not cited yet (Europe PMC); 78 references in the paper

Abstract

Planning is critical for adaptive behaviour in a changing world, because it lets us anticipate the future and adjust our actions accordingly. While prefrontal cortex is crucial for this process, it remains unknown how planning is implemented in neural circuits. Prefrontal representations were recently discovered in simpler sequence memory tasks, where different populations of neurons represent different future time points. We demonstrate that combining such representations with the ubiquitous principle of neural attractor dynamics allows circuits to solve much richer problems, including planning. This is achieved by embedding the environment structure directly in synaptic connections to implement an attractor network that infers desirable futures. The resulting ‘spacetime attractor’ excels at planning in challenging tasks known to depend on the prefrontal cortex. Recurrent neural networks trained by gradient descent on such tasks learn a solution that precisely recapitulates the spacetime attractor – in representation, in dynamics, and in connectivity. Analyses of networks trained across different environment structures reveal a generalisation mechanism that rapidly reconfigures the world model used for planning, without the need for synaptic plasticity. The spacetime attractor is a testable mechanistic theory of planning. If true, it would provide a path towards detailed mechanistic understanding of how prefrontal cortex structures adaptive behaviour.

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

Repository

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

KrisJensen/pysta

License: MIT
State: the link answers, verified on 25 October 2026
Evidence: files inventoried
Commit: fdd6dd39df54a67aa451a24d3adc280f25136601, 10 October 2026
Languages: Python (48)
Size: 54 files, 48 scripts
Software Heritage: not archived
Found in: “Data availability”
Holds: README, license file, environment (requirements.txt, setup.py), tests
Not found: CITATION.cff, continuous integration, documentation
Tools: NumPy (37 files), PyTorch (30 files), Matplotlib (22 files), SciPy (9 files), scikit-learn (4 files)
Availability: 1 check, the latest on 25 October 2026: the link answers
  • 25 October 2026: the link answers
50 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:

  • 1 repository of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 48 scripts, each with its path and the digest of its content;
  • 12 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.

Data availability

This is a computational manuscript, and no new data has been generated. Code is available at https://github.com/KrisJensen/pysta (copy archived at Jensen, 2026). This includes code for running example models and reproducing all results from the 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, 25 October 2026: the first record

Recorded: type, language, journal, volume, pages, dates, 8 authors, 5 keywords, 8 MeSH terms, 9 funders, 68 references.

Cite

This paper

Jensen, K. T., Doohan, P. T., Sablé-Meyer, M., Reinert, S., Baram, A. B., Sahani, M., Akam, T., & Behrens, T. E. (2026). A mechanistic theory of planning in prefrontal cortex. eLife, 15, RP109757. https://doi.org/10.7554/elife.109757

BibTeX

@article{jensen2026mechanistic,
author = {Jensen, Kristopher T and Doohan, Peter T and Sablé-Meyer, Mathias and Reinert, Sandra and Baram, Alon Boaz and Sahani, Maneesh and Akam, Thomas and Behrens, Timothy E},
title = {{A mechanistic theory of planning in prefrontal cortex}},
journal = {eLife},
year = {2026},
month = oct,
volume = {15},
pages = {RP109757},
publisher = {eLife Sciences Publications, Ltd},
issn = {2050-084X},
doi = {10.7554/elife.109757},
url = {https://doi.org/10.7554/elife.109757},
pmid = {42904774},
pmcid = {PMC13686619}
}

RIS

TY - JOUR
AU - Jensen, Kristopher T
AU - Doohan, Peter T
AU - Sablé-Meyer, Mathias
AU - Reinert, Sandra
AU - Baram, Alon Boaz
AU - Sahani, Maneesh
AU - Akam, Thomas
AU - Behrens, Timothy E
TI - A mechanistic theory of planning in prefrontal cortex
T2 - eLife
J2 - eLife
PY - 2026
DA - 2026/10/21
VL - 15
SP - RP109757
SN - 2050-084X
PB - eLife Sciences Publications, Ltd
DO - 10.7554/elife.109757
UR - https://doi.org/10.7554/elife.109757
LA - en
ER -

CSL-JSON

{
"id": "10.7554/elife.109757",
"type": "article-journal",
"title": "A mechanistic theory of planning in prefrontal cortex",
"container-title": "eLife",
"author": [
{
"family": "Jensen",
"given": "Kristopher T"
},
{
"family": "Doohan",
"given": "Peter T"
},
{
"family": "Sablé-Meyer",
"given": "Mathias"
},
{
"family": "Reinert",
"given": "Sandra"
},
{
"family": "Baram",
"given": "Alon Boaz"
},
{
"family": "Sahani",
"given": "Maneesh"
},
{
"family": "Akam",
"given": "Thomas"
},
{
"family": "Behrens",
"given": "Timothy E"
}
],
"container-title-short": "eLife",
"volume": "15",
"page": "RP109757",
"DOI": "10.7554/elife.109757",
"PMID": "42904774",
"PMCID": "PMC13686619",
"ISSN": "2050-084X",
"publisher": "eLife Sciences Publications, Ltd",
"URL": "https://doi.org/10.7554/elife.109757",
"language": "en",
"issued": {
"date-parts": [
[
2026,
10,
21
]
]
}
}

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/s41586-024-08145-x [code]
A cellular basis for mapping behavioural structure.
Journal: Nature
In common: scikit-learn, SciPy, Matplotlib, 1 other tool, computational modeling (no new data), 9 references, 2 authors
[2] doi:10.1038/s41593-024-01675-7 [code]
A recurrent network model of planning explains hippocampal replay and human behavior.
Journal: Nature neuroscience
In common: NumPy, computational modeling (no new data), 8 references, author Kristopher T Jensen
[3] doi:10.1038/s41593-022-01149-8 [code]
Complementary task representations in hippocampus and prefrontal cortex for generalizing the structure of problems.
Journal: Nature neuroscience
In common: scikit-learn, SciPy, Matplotlib, 1 other tool, 4 references, 2 authors
[4] doi:10.1038/s41593-025-01908-3 [code]
Constructing future behavior in the hippocampal formation through composition and replay.
Journal: Nature neuroscience
In common: PyTorch, SciPy, Matplotlib, 1 other tool, computational modeling (no new data), 6 references, author Timothy E Behrens
[5] doi:10.1038/s42256-026-01254-4 [code]
Neural sampling from cognitive maps enables goal-directed imagination and planning.
Journal: Nature machine intelligence
In common: PyTorch, scikit-learn, Matplotlib, 1 other tool, computational modeling (no new data), 9 references
[6] doi:10.7554/elife.101134 [code]
Flexible neural representations of abstract structural knowledge in the human entorhinal cortex.
Journal: eLife
In common: SciPy, NumPy, 2 references, 2 authors
[7] doi:10.1371/journal.pcbi.1010547 [code]
Multiple bumps can enhance robustness to noise in continuous attractor networks.
Journal: PLoS computational biology
In common: PyTorch, SciPy, Matplotlib, 1 other tool, computational modeling (no new data), 8 references
[8] doi:10.1038/s41467-025-64591-9 [code]
Hippocampus supports multi-task reinforcement learning under partial observability.
Journal: Nature communications
In common: PyTorch, scikit-learn, SciPy, 2 other tools, 8 references
[9] doi:10.1038/s41467-024-54257-3 [code]
A hierarchical active inference model of spatial alternation tasks and the hippocampal-prefrontal circuit.
Journal: Nature communications
In common: PyTorch, SciPy, Matplotlib, 1 other tool, 8 references
[10] doi:10.7554/elife.106032 [code]
Neural signatures of model-based and model-free reinforcement learning across prefrontal cortex and striatum.
Journal: eLife
In common: scikit-learn, SciPy, Matplotlib, 1 other tool, 4 references, author Timothy E Behrens

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.7554/elife.109757.svg)](https://openscicode.org/badge/doi/10.7554/elife.109757)
<!-- oscr-badge:end -->
reStructuredText
.. oscr-badge:start

.. image:: https://openscicode.org/badge/doi/10.7554/elife.109757.svg
   :target: https://openscicode.org/badge/doi/10.7554/elife.109757
   :alt: OSCR

.. oscr-badge:end
HTML
<!-- oscr-badge:start -->
<a href="https://openscicode.org/badge/doi/10.7554/elife.109757"><img src="https://openscicode.org/badge/doi/10.7554/elife.109757.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.