tem_tf2 · field guide

Running the Tolman‑Eichenbaum Machine

What this model assumes, what happens on every one of its two million training steps, and how to read every number it prints at you.

Model Whittington et al., Cell 183 (2020) Code tem_tf2 · TensorFlow 2 Entry point run_tem.py

01The problem TEM is built to solve

Imagine you walk into a room you have never seen before. You take four steps: north, east, south, west. You are back where you started, and you knew that before you got there. Nobody had to teach you this room. You already knew the rule, because the rule belongs to space itself, not to any particular room.

That is the whole idea. TEM separates two kinds of knowledge that are usually tangled together:

The claim in the paper is that the brain keeps these in different places. Grid cells in medial entorhinal cortex carry the reusable structure. Place cells in hippocampus bind that structure to whatever you are currently seeing. Because TEM stores structure separately, a new environment can reuse it immediately. That is what lets you predict correctly in a room you have never visited.

The model has three representations. Almost everything else in the codebase exists to keep them honest. Every diagram below uses this colour coding.

g · structural code Where am I in the abstract structure? Updated by actions alone. Grid cells. x · sensory code What can I see right here? One of 45 possible stimuli. p · place code Every pairing of "structural slot" with "thing seen there". outer product, then flattened Memory M Hebbian matrix. Written once, retrieved forever. store REUSED ACROSS ENVIRONMENTS ↓ REBUILT IN EVERY ENVIRONMENT ↓
The central architecture. TEM holds structure (g) and sense (x) apart, then binds them into place cells (p). Only p reaches memory. Because g never learns anything room-specific, the weights that update it stay valid when you drop the agent into a new room. That is where generalisation comes from.

02The world the agent lives in

TEM never sees pixels or coordinates. Its world is a graph: a set of nodes connected by labelled edges. At each node sits one sensory observation drawn from 45 possibilities. The agent takes a random walk, and at every step it must answer one question: what am I about to see next?

That question is the entire training signal. There are no labels, no rewards driving the loss, no supervision beyond "predict the next observation". Everything the model learns, grid cells included, falls out of getting better at that one task.

pig cat random walk on a graph The rule that must be learned north → east → south → west = back where you started Having seen "pig" and walked that loop, the model should predict "pig" again, even if it has never taken that exact sequence of actions before. THIS IS THE ZERO-SHOT TEST Predicting correctly on an edge never taken, to a node already seen, is only possible if the rules of space have actually been learned.
Prediction as the only objective. Memorising sequences gets you nowhere on a random walk. The only way to predict the next observation is to infer the structure of the graph and use it. The default world in parameters.py is not a plain grid but frank2000, a W‑maze alternation task with reward sites. The shipped settings give it 52 nodes.

The default world: frank2000, a W-maze

The name comes from Frank, Brown and Wilson (2000), a rat experiment on continuous spatial alternation. The animal runs a W-shaped track with three arms. From the centre arm it must go to one side arm, return, then go to the other side arm. Reward arrives only when the alternation is correct, so the right answer at the choice point depends on where you came from, not on where you are. A purely spatial map cannot solve it.

Frank2000 in environments.py builds this as a graph rather than a track. With the shipped settings (height=5, width=1) it has 4*(h-1+h+w) + 2*(w+h) = 52 nodes: a 40-node main circuit that runs both correct alternation loops, plus two 6-node error branches for the wrong turns. Four nodes are rewarded (0, 10, 20, 30) and two are the unrewarded ends of the error branches (45, 51).

THE TASK R R R centre stem left, centre, right, centre, left ... reward only if the sides alternate AS A GRAPH, AS TEM SEES IT 52 nodes. Each carries one of 45 sensory classes, assigned at random when the environment is created. 0 → 39 main circuit, both alternation loops, rewards at 0, 10, 20, 30 40 → 51 two error branches, dead ends at 45 and 51, no reward A wrong turn is taken with probability error_prob = 0.1 at each choice point, which is what puts the agent on an error branch at all. The two dead ends look identical to a place-only code. Telling them apart needs the history, which is what the task is testing.
Why this world and not a plain grid. Alternation makes the correct prediction depend on the route taken, so the model cannot pass by learning positions alone. Cross-entropy at the six reward and no-reward nodes is multiplied by reward_val, which each environment sets to n_states/2, so 26 here. Those nodes dominate the loss on purpose.

Drawn out, that graph is a single directed 40‑node cycle with two 6‑node cul‑de‑sacs hanging off it. Nothing in the code knows about arms or corridors: the W‑shape only shows up in which physical stretch each run of node indices happens to correspond to, and in the fact that the centre arm is traversed four times per full circuit while each side arm is traversed twice.

THE 52-NODE GRAPH, AS environments.py BUILDS IT (height=5, width=1) centre ↓ → left arm left ↑ left ↓ → centre centre ↑ centre ↓ → right arm right ↑ right ↓ → centre centre ↑ 0 R 1 2 3 4 5 6 7 8 9 10 R 11 12 13 14 15 16 17 18 19 20 R 21 22 23 24 25 26 27 28 29 30 R 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 NR 46 47 48 49 50 51 NR HOW IT IS BUILT n_states = 6h + 6w + 4(h-1) = 52 with h=5, w=1. 0 - 39 main circuit One directed cycle, 39 back to 0. It walks centre-left-centre-right and repeats, so the centre arm appears four times as four nodes. 40 - 51 two error branches Six nodes each. Entered from either arm of a loop (dashed), run to an unrewarded dead end, then rejoin the circuit on the other side. EDGES OUT OF THE CIRCUIT 4, 16 → 40 … 45 (NR) → 31 24, 36 → 46 … 51 (NR) → 11 Each dashed edge carries error_prob = 0.1 in tran, so a wrong turn is taken one time in ten. The branches are unidirectional: once on one, the agent runs it to the end before rejoining. SENSES state_data gives the four traversals of one corridor the same sensory class, and each error branch the senses of the corridor it shadows. The four reward and two no-reward nodes get two classes of their own.
The graph Frank2000.world() actually builds. adj is a 52×52 matrix: a chain i → i+1 for the first 40 nodes with 39 → 0 closing the loop, two chains for the error branches, and six hand‑written edges joining them. tran is adj row‑normalised, with the branch entries weighted p/(1-p) before normalisation so each choice point leaks 10% of its probability onto the wrong turn. Node 0 is always the start state.
The agent does not act, and reward is not a signal

Both words invite the wrong picture. The walk is not produced by the model: env.walk() is called once when the environment is created, and it generates the whole trajectory up front by sampling nothing but the environment's own tran matrix. The model is then told which action was taken, as the input d. It never chooses one. There is no policy, no value function and no reinforcement learning anywhere in the code. Wrong turns onto the error branches are the environment's dice roll at error_prob, not the model's mistakes.

Reward is a sensory class like the other forty‑four, drawn at random for each environment. The agent sees it; it does not collect it. What makes those six nodes special is a single line in compute_losses, where cross‑entropy is multiplied by reward_val. That is a weight on the prediction objective, not a reinforcement.

So the alternation task here is not the behavioural one. It is not “choose the correct arm” but “on arriving at the top of the centre arm, predict whether you are about to see the reward sense or the no‑reward one”. Those two nodes are the same physical place, so the only way to call it is to know which loop you are on, which is exactly the history that a place‑only code throws away.

What the defaults give you

World
frank2000
Nodes
52
Sensory classes
45
Batch (envs)
16
Steps per update
75
g cells
120
p cells
400
Training steps
2,000,000

What "batch (envs)" means here

In most networks a batch is a stack of independent examples. Here it is a stack of 16 independent worlds, run in parallel for the whole of training. params.n_envs = params.batch_size = 16, and the two numbers are the same thing by construction.

Each of the 16 gets its own graph, its own random assignment of 45 sensory classes to nodes, its own walk, and its own Hebbian memory matrix. Nothing is shared between them except the weights. That is what forces the weights to hold what is common to all worlds: anything specific to one of the 16 belongs in that world's memory matrix, not in a weight.

The walks are not synchronised (asyncrounous_envs = True). Each environment draws its own walk length, and when one ends, a fresh environment takes that slot in the middle of a batch while the other 15 carry on. The model therefore always sees a mix of familiar and brand-new worlds. So the model never learns one map. It learns what all maps have in common.

03Steps, updates, and what gets counted

Three different things in this code are called a step, and the log prints all three. Getting them mixed up makes every runtime estimate wrong by a factor of 16 or 75.

NameIn codeWhat it isSize
Environment stepone iteration of the seq_len loop One move along one edge, in one environment. The six stages in section 04 happen once. 1
Sequenceseq_len The truncation window for backpropagation through time. Gradients flow across these 75 steps and stop at the boundary. The grid code and both memory matrices carry over to the next sequence, detached from the graph. 75 steps
Gradient updatetrain_i One call to train_step: run the sequence forward for all 16 environments, sum the losses, take one Adam step. This is the unit every schedule in section 07 counts in. 16 × 75 = 1,200 steps
total_steps in the logtrain_i * seq_len Steps taken by one environment. It ignores the batch, so the model has really seen 16 times this many transitions. 75 per update

So the configured maximum of 2,000,000 gradient updates is 2.4 billion environment steps. Nobody runs that. The paper's figure is 50,000 updates, and useful structure appears well before then.

What one gradient update does

Adam, with the learning rate set fresh on every iteration from the schedule (9.4e-4 - 8e-5) * 0.5^(train_i/4000) + 8e-5. That halves the rate every 4,000 updates and floors it at 8e-5. Every gradient is clipped to norm 2 before it is applied, which is what keeps the Hebbian matrix from blowing the whole run up early on.

Walk length is itself a curriculum

An environment's walk is n_restart laps of its graph, where n_restart starts at restart_max = 40 and decays toward restart_min = 10 as training proceeds, plus a jitter of up to 30 laps. Early on the agent gets roughly 2,000 steps to learn a 52-node world. Later it gets a quarter of that. The task quietly gets harder.

04What happens in a single step

This loop is the model. It runs 75 times per gradient update, and it is worth understanding completely, because every metric you read later is measured at one of these stages.

1 · PATH INTEGRATE Move g using only the action taken. 2 · RETRIEVE Ask memory what was seen at this g. 3 · PREDICT Guess the next observation. 4 · OBSERVE See what is really there. Score it. 5 · CORRECT Fix drift in g using what was seen. 6 · STORE Write the new memory. next timestep, carrying g and memory forward What each stage is allowed to know g₀ PATH-INTEGRATED actions only. Has NOT seen the current observation this is the honest prediction g INFERRED actions + memory evidence. Drift already corrected p INFERRED actions + memory + the current observation itself. Knows the answer Longer bar = more information. The metrics in section 08 are simply "how well did the model predict, given each of these three levels of access?"
One timestep, and the information ladder that defines every metric. Predicting from path integration alone (g_gen) is a real forecast. Predicting from p is closer to reading the answer off the page. The log prints both, and confusing them is the easiest way to misread a training run.

What "path integrate" actually computes

Path integration is dead reckoning: update your estimate of where you are from the movement you just made, with no reference to what you can see. TEM does it with a matrix built from the action.

# tem_model.py, g2g() and get_g2g_update()
T     = reshape(t_vec(d), [16, 120, 120]) * mask_g   # action d becomes a transition matrix
g_gen = clip(g_prev + T @ g_prev, -1, 1)             # the whole of path integration

d is the action just taken, one of up, down, left, right in this world. A small network turns it into a 120 × 120 matrix, so the model learns a separate linear operator per action rather than being handed one. mask_g then zeroes most of that matrix: modules may only be driven by modules at their own timescale or slower, which is the hierarchy of section 06.

Two properties matter. The update is a function of the action alone, so g_gen cannot smuggle in the current observation. And it is applied over and over, so error accumulates: after enough steps the estimate has drifted, exactly as an animal's does.

What "fix drift in g" means

Correcting that drift is the job of p2g, and it works by pattern completion. Take what you can see right now, use it to query memory, and see which structural slot the memory that comes back was stored in.

  1. The current observation becomes place-cell input x2p.
  2. That runs through the attractor over the inverse memory matrix, giving a retrieved place code p_x.
  3. Averaging p_x over the 10 sensory components of each phase strips the sensory part back out, leaving a structural estimate. A per-module layer maps it to mu_p2g.
  4. A second head produces that estimate's uncertainty from how well the retrieved memory reconstructs the observation. A bad retrieval reports a large sigma.
  5. combine2 fuses the two estimates by precision, the standard Gaussian product: mu = sigma² (mu_path/sigma_path² + mu_mem/sigma_mem²). Whichever source is more confident wins.

That fusion is the whole of drift correction. Path integration proposes, memory corrects, and the weighting is learned rather than fixed. The result is g, as opposed to g_gen.

Why the correction is switched off at the start

Memory is nonsense on update 1, so trusting it would poison g. The code adds (1 - p2g_use) * 10000 to sigma_p2g, an uncertainty so large that combine2 ignores memory entirely. As p2g_use rises from 0 to 1 over the first few hundred updates, memory is let in. This is the single most important thing the curriculum does.

Three phrases this guide uses precisely

The same underlying question, "what will I see next", gets asked three times per step from three different states. The shorthand for each:

PhrasePredictionKnowsDoes not know
Honest prediction, or real forecast x_gt = f_x(gen_p(g_gen)) Every action taken so far, and everything in memory The observation it is being scored against. It is a forecast in the ordinary sense: made before the fact, from a state that cannot contain the answer.
Corrected prediction x_g = f_x(gen_p(g)) The same, plus the drift correction that used the current observation to fix g The answer directly, though the correction has touched it. This is why aGinf sits above aGt.
Reading the answer off the page x_p = f_x(p) Everything, including the current observation, which went into p through x2p Nothing. Predicting x from a code built out of x is an autoencoder round trip, so a high score here proves that the encoder and decoder are wired up, and nothing about structure.

All three are trained, and all three should be. The autoencoder path has to work before anything else can. The mistake is quoting aP as the model's performance.

05Memory: one matrix, written in one shot

TEM does not keep a list of past events. It keeps one matrix, a_rnn in the code, and adds every new memory into it by Hebbian learning. Retrieval is not a lookup. It is an attractor process that settles onto the stored pattern closest to what you asked for.

STORING p × p → added into M One shot. No gradient step required. RETRIEVING g attractor loop settles onto nearest memory p retrieved x predicted Memories decay by λ = 0.9999 per step. Because TEM writes memory instantly but changes weights slowly, it can learn a new room in one pass while its structural knowledge stays fixed. That split is what the architecture is for.
Fast memory, slow weights. The Hebbian matrix is what makes one-shot learning of a new environment possible. TEM keeps two of them, a_rnn and a_rnn_inv, one for each direction of retrieval.

How a memory is actually added

The textbook Hebbian write is M += p pᵀ. TEM does not do that. It writes the difference between what it inferred and what it predicted, which stops it re-storing things it could already retrieve.

# tem_model.py, hebbian() collects these each timestep
a = p - p_g          # inferred place code minus the one generated from memory
b = p + p_g

# final_hebbian(), once per 75-step sequence
M ← clip( M * (forget * λ)^75  +  b @ (a * forget_vec)ᵀ * mask_p,  -1, +1 )

Read the update term as a product: b aᵀ = p pᵀ - p_g p_gᵀ + p_g pᵀ - p p_gᵀ. The first part stores the new pattern. The rest subtracts off what memory already generated. If the model predicted the place code perfectly then p = p_g, a = 0, and nothing is written at all. This is the fast-weights rule from Ba et al. (2016), and it is why memory does not saturate on repeated visits to the same node.

TermValueJob
λ0.9999 Passive decay per step. Old memories fade whatever else happens.
forget0 → 1 over 200 updates Curriculum multiplier on that decay. At update 1 it is 0.005, so (forget * λ)^75 is effectively zero and each sequence starts from a blank matrix.
forget_vec(forget λ)^(74...0) Within one sequence, memories laid down earlier are already more faded than the ones at the end.
η0.5 Write strength. Enters as sqrt(η * h_l) on each stored vector, so the product of two of them carries the full η * h_l.
h_l0 → 1 over 16,000 updates The curriculum on write strength. The slowest ramp in the model.
mask_p0/1 matrix Blocks connections between place modules that the hierarchy does not allow.
hebb_lim±1 Hard clip on every entry. MaxHebb in the log is the largest absolute value, so it can never exceed 1. If it pins at 1, memory has saturated.

Two matrices are built this way. a_rnn uses a, b from p_g and retrieves a place code from a structural query. a_rnn_inv uses p - p_x, p + p_x and retrieves in the other direction, from sensory input, which is what p2g needs to correct drift.

The attractor loop

Retrieval is iterative. You start from a partial or noisy pattern, multiply by the memory matrix, damp, clip, and repeat. Each pass pulls the state further toward whichever stored pattern it most resembles.

p = f_p(init)
for i in 0..4:                              # n_recurs = n_freq = 5
    update = M @ p                          # as scalar products, not a real matmul
    for f in modules still active at i:
        p[f] = f_p(0.8 * p[f] + update[f])  # kappa = 0.8

kappa = 0.8 is how much of the current state survives each pass. Below 1 the loop contracts toward a fixed point instead of ringing. f_p is a leaky ReLU on values clipped to [-1, 1], so activity cannot run away.

The loop is not uniform across modules. max_attractor_its = [5, 4, 3, 2, 1] stops each module after a set number of passes, fastest module first to finish last:

ATTRACTOR ITERATION 1 2 3 4 5 module 0 100 p cells update update update update update module 1 100 p cells update update update update held module 2 80 p cells update update update held held module 3 60 p cells update update held held held module 4 60 p cells update held held held held Slow modules stop early because a coarse code has less to settle. The fast module keeps iterating.
Early stopping, per module. Module 0 gets all five passes, module 4 gets one. The schedule is max_attractor_its = [n_recurs - f], straight out of parameters.py, and it is why retrieval costs less than five full matrix products.

06Grid cells come from frequency bands

The 120 g cells are not one undifferentiated pool. They are split into five modules that are forced to update at different rates, from fast to slow. This is a structural prior, hard-coded in parameters.py, and it is the reason grid-like cells of several different scales appear after training.

g CELLS PER MODULE · n_grids_all 30 30 24 18 18 fastest, small spatial scale slowest, large scale EACH PAIRS WITH SENSORY CODE → p CELLS · n_place_all 100 100 80 60 60 120 g cells total · 400 p cells total. In the notebook, plots are grouped by these five bands when split_freqs is on.
The module hierarchy. Slower modules end up with wider grid spacing, like the discrete modules found in entorhinal cortex. When you look at rate maps later, expect the leftmost band to show tight periodic patterns and the rightmost to show broad ones.
IDEALISED FIRING LATTICE PER MODULE, SAME ENVIRONMENT IN EVERY PANEL module 0 30 g cells 100 p cells · smoothing 0.01 module 1 30 g cells 100 p cells · smoothing 0.70 module 2 24 g cells 80 p cells · smoothing 0.91 module 3 18 g cells 60 p cells · smoothing 0.97 module 4 18 g cells 60 p cells · smoothing 0.99 fast module: sensory input barely smoothed, code changes on almost every step, tight spacing slow module: heavily smoothed, changes over many steps, wide spacing
Same room, five scales. Each panel is one module's firing pattern over the same environment. Nothing in the code draws these lattices. They come out of the timescale split: a module whose sensory input is smoothed with constant 0.99 cannot change quickly, so whatever it encodes has to repeat over a longer distance. The number under each panel is the initial smoothing constant from params.freqs, and it is trainable, so the model can move these scales.

What "structural prior" means

A structural prior is a constraint built into the architecture rather than learned from data. It does not say what the answer is. It restricts the shape of the answers the model can represent, so that the ones the world actually supports are easy to reach. TEM has five, all in parameters.py, and grid cells do not appear without them.

PriorWhereWhat it forces
Split into 5 modulesn_grids_all = [30,30,24,18,18] The structural code cannot be one undifferentiated pool.
A timescale per modulefreqs, gamma Module f smooths its sensory stream as xᵗ = a xᵗ₋₁ + (1-a) x with its own a. Fast modules track single steps, slow ones average over many. Trainable, but it starts split.
Hierarchical transitionsmask_g, R_G_F_f A module can be driven by its own timescale and slower, never by a faster one. Coarse structure informs fine, not the reverse.
Place cells are a productn_place_all = n_phases * 10 p is the outer product of a structural phase with a sensory component, then flattened. It cannot represent a location without representing what is there.
Two-hot sensory codetwo_hot = True, s_size_comp = 10 Each of the 45 stimuli is a pair of active units out of 10, and C(10,2) = 45 exactly. Stimuli share units, so the model cannot treat them as 45 unrelated symbols.

Nothing here mentions hexagons, spacing, or space at all. That is the claim worth taking seriously: give a predictive model a factorised code, a hierarchy of timescales, and a fast Hebbian memory, and grid cells fall out of prediction on graphs.

07The training curriculum

You cannot train TEM by switching everything on at once. The memory would be garbage on step one, and inference that trusted it would never recover. Instead several quantities ramp up over the first few thousand steps. These are the scalings printed in the log, and knowing their schedule tells you whether a run is behaving.

1.0 0.0 scaling value 0 200 2,000 4,000 16,000 >16k gradient updates (axis compressed, ticks mark the schedule constants) forget · 200 p2g_use · sigmoid, scale 200 temp · 2,000 hebb_learn · 16,000
What ramps, and when. forget reaches full strength almost immediately; temp (the weight on the consistency losses) takes 2,000 updates; hebb_learn takes 16,000. One quantity runs the opposite way: p_cell_reg decays from 1 to 0 over 4,000 updates, so the sparsity penalty on p constrains early representations and then lets go. Its counterpart g_cell_reg is scheduled over 40,000,000 updates, which means it never meaningfully moves.

Which quantities ramp, and what each one gates

"Several quantities ramp up" means five scalars, recomputed from the update counter on every iteration by get_scaling_parameters and passed into the model as scalings. They are not learned. They are a fixed function of train_i.

ScalingFormulaFull byWhat it gates
forget min((i+1)/200, 1) 200 Multiplies λ in every memory decay term. At 0.005 on update 1 the matrix is wiped each sequence, so the model is forced to learn path integration before it can lean on memory at all.
p2g_use sigmoid(i/200) ~600 Adds (1 - p2g_use) * 10000 to the uncertainty of the memory-based estimate of g. While it is near 0, drift correction is switched off. It also scales the lp_x loss. Note it starts at 0.5, not 0, because the sigmoid is centred on p2g_use_it = 0.
temp min((i+1)/2000, 1) 2,000 Weight on the consistency losses lp, lg and lp_x. Demanding that inference and generation agree is meaningless while both are noise, so it is phased in.
h_l min((i+1)/16000, 1) 16,000 Write strength, as sqrt(η * h_l) on every stored vector. The slowest ramp, and the reason accuracies before 16,000 updates do not mean much.
p_cell_reg 1 - min((i+1)/4000, 1) 0 by 4,000 Runs the other way. The L1 sparsity penalty on p is strong early, then released once place cells have taken shape.
g_cell_reg 1 - min((i+1)/4e7, 1) never, in practice g_reg_it is 40,000,000, so after 50,000 updates this is still 0.9988. The L2 penalty on g is effectively constant. Treat it as a fixed regulariser rather than a schedule.

The order is the point, and it is the order of dependency. Forgetting comes first so nothing trusts an empty memory. Then memory is allowed into inference. Then the consistency terms start to bite. Write strength arrives last, once there is something worth writing. Reverse any two and the run fails in a way that looks like a bug in the model.

Read this before judging an early run

Accuracy figures before roughly 16,000 updates are not meaningful. The memory system is still being phased in. A run that looks mediocre at step 2,000 is simply a run at step 2,000.

08Every metric, in plain terms

run_tem.py appends two lines to summaries.log every 100 updates. These are real numbers from a short run of this code:

MaxHebb=0.10516, T=0.10, F=1.00, L=0.01, train_i=200, total_steps=15000
it=200, lxP=2.25, lxG=6.02, lxGt=6.02, lp=0.07, lg=0.00, aP=89.08, aGinf=15.75, aGt=15.75

What "loss" means in this model

A loss is one number that the optimiser tries to make smaller. It is not an error rate and it has no upper bound. TEM's is a plain sum of eight terms, assembled in compute_losses, and they are not interchangeable. They fall into three kinds:

A useful anchor for the cross-entropy terms: predicting all 45 stimuli as equally likely gives ln(45) = 3.81. So lxGt = 6.02 in the sample log above is worse than guessing, which is what an untrained network that is confidently wrong looks like. Below 3.81 the model is doing something. The consistency and regularisation terms have no such reference point, and only their trend is meaningful.

How each term is aggregated

# per timestep, then summed over the 75 steps of the sequence
norm  = 1 / (n_visited_in_batch * 75)
lx_gt += sum( CE(x, x_gt) * s_visited * x_mult ) * norm
lp    += sum( 0.5*||p - p_g||² * s_visited ) * temp * norm
lg_reg+= sum( ||g||² * s_visited ) * 0.01 * g_cell_reg * norm

train_loss = lx_p + lx_g + lx_gt + lp + lg + lp_x + lg_reg + lp_reg

Three things to read off that. Every term is masked by s_visited. Every term is normalised by how many states in the batch counted, so the numbers stay comparable as that varies. And the curriculum scalars multiply the losses directly, so lp and lg are near zero early on because temp is near zero, not because the model is doing well.

x_mult is the reward weighting: at the six reward and no-reward nodes of the W-maze, cross-entropy is multiplied by 26. A model that predicts everything except the reward nodes will show a bad loss and a decent accuracy at the same time.

The three accuracies, and which one counts

All three ask "did the model predict the right one of 45 stimuli?" They differ only in what the model was allowed to know when it guessed. Chance is 1/45 ≈ 2.2%.

Log nameIn codePredicts fromWhat it tells you
aP accuracies.p Inferred place cells p, which have already absorbed the current observation A sanity check, not a result. It should climb fast and go high. It mostly says the autoencoder path works. High aP with low aGt means the model is copying, not understanding.
aGinf accuracies.g Inferred g, after memory evidence has corrected path integration How good memory retrieval is once you are confident about where you are. Sits between the other two.
aGt accuracies.gt Path-integrated g_gen only. Actions, no peeking This is the headline number. A genuine forecast made before observing. This is where generalisation shows up, and the figure to quote.

The expected ordering is aP ≥ aGinf ≥ aGt, because that is the order of decreasing information. Watch the gap between aP and aGt. It shows how much the model leans on seeing the answer rather than knowing the structure.

100% 50% 0% 0 training updates → chance 2.2% aP aGinf aGt Shape, not measured data: the point is the ordering and the persistent gap between aP and aGt.
What a healthy run looks like. Schematic. If aGt hugs the chance line while aP climbs to 90%, the model is memorising the current observation and has learned no structure at all.

The losses

Each accuracy has a matching cross-entropy loss, plus the consistency and regularisation terms. All are printed with the same prefixes. The which_costs list in parameters.py decides which ones are summed into train_loss; the default includes all eight below. weight_reg, an L2 penalty on the network weights, is implemented but left out of that list.

TermTypeWhat it pushes toward
lx_pcross-entropy Prediction from inferred p should match the observation. Partner of aP.
lx_gcross-entropy Prediction from inferred g via memory. Partner of aGinf.
lx_gtcross-entropy Prediction from path integration alone. Partner of aGt, the one that matters.
lpsquared error The place code you inferred and the one you generated from memory should agree. Keeps memory honest.
lgsquared error Where you think you are and where path integration says you are should agree. This is the term that makes g a consistent map rather than drifting noise.
lp_xsquared error Place code inferred from sensory input should match the full inference. Only active when infer_g_type contains p, which it does by default.
lg_regL2 penalty Keeps g activity small. Weight decays to zero over training.
lp_regL1 penalty Keeps p activity sparse, with L1 rather than L2. Sparsity is what makes place cells look like place cells.
A subtlety that changes what the numbers mean

s_visited masks the losses, so the model is scored only on states it has already visited in the current walk. Predicting a node you have never been to is information-theoretically impossible, so those steps drop out. Every accuracy here therefore means "accuracy where prediction was possible at all", not accuracy over every step.

The scalings line

LogNameMeaning
TtempWeight on the consistency losses (lp, lg). Ramps 0→1 over 2,000 updates.
FforgetMemory decay strength. Reaches 1 within 200 updates.
Lh_lHebbian learning rate scaling. Ramps over 16,000 updates, the slowest schedule.
MaxHebb·Largest absolute value in the Hebbian matrix. It should grow and then settle, not explode.

09Actually running it

Training writes into a dated run folder outside the source directory, and copies the scripts it ran alongside the results, so you can always trace an old run back to the code that produced it.

conda activate tem
cd tem_tf2
python run_tem.py

# watch it, from the repo root
tensorboard --logdir=Summaries
run_tem.py the training loop Summaries/<date>/run<N>/ train/TensorBoard event files model/checkpoints, every 666 updates save/ saved forward-pass data script/a copy of the code that ran *.log summaries.log, env_details.log TEM_notebook.ipynb loads a checkpoint, runs a forward pass, draws the cell rate maps The notebook needs three things set in its first cell: the path to Summaries, the date folder, and the run number. Run it from inside tem_tf2. It imports the sibling modules and uses relative paths.
Where everything lands. Checkpoints are saved every int(50000/75) = 666 updates. The script/ copy means a run remains reproducible even after you edit the source.

Rough expectations

MilestoneUpdatesNotes
Graph compilationbefore step 1Several minutes of apparent hang with graph_mode=True. Expected, not a crash.
First checkpoint0Untrained. Loading it gives noise-like rate maps.
Curriculum complete~16,000Only now are the accuracies worth reading.
Paper-scale trainingup to 50,000The figure quoted in the TEM paper for full training.
Configured maximum2,000,000train_iters. Stop it long before this.

10Reading the results

Loss curves tell you the model trained. They do not tell you what it learned. Only the cells answer that, and the notebook exists to draw them.

A rate map is the simplest possible object: for one cell, colour every location in the environment by how active that cell was there, averaged over the walk. What pattern appears tells you what kind of cell the network invented on its own.

GRID Repeating hexagonal lattice. In g cells. BAND Stripes. A grid cell with one direction. PLACE One spot only. In p cells. BORDER Fires along an edge of the space. NO STRUCTURE Undertrained, or the cell is unused. Expect the first four only after the curriculum has finished.
What to look for in square_plot output. The paper's claim is that nobody designed these cell types in. They emerge from the single objective of predicting the next observation, given the split between structure and sense.

How a rate map is computed

There is no smoothing over space and no notion of a pixel. rate_map_from_timeseries does one thing: bin cell activity by the node the agent was standing on, then average.

# plotting_functions.py, one environment
for each timestep t:
    bucket[ position[t] ].append( activity[:, t] )
rate_map[node] = mean( bucket[node] )        # zeros if never visited

The result is a table of n_states × n_cells, so 52 × 120 for the grid cells in the default world. square_plot then places those 52 numbers into the shape of the environment for display. The optional smoothing argument runs a Savitzky-Golay filter along time before binning, not across space.

Two consequences worth keeping in mind. A node the walk never reached reads as exactly zero, which looks like silence rather than missing data. And each node's value is an average over every visit, including visits arriving from different directions, so a cell that fires only on one approach shows up washed out.

The other number the notebook prints

Cell 2 reports, per environment, the proportion of visited states predicted correctly:

0   Num_states : 52   Approx proportion : [0.051]   Approx num : [3.]

Read this as: of the states this agent visited, about 5.1% were predicted correctly, roughly 3 states out of 52. Compare against chance of 2.2%. The example above comes from an untrained checkpoint, which is exactly what near-chance looks like.

The most common mistake

Loading iteration 0 and concluding the model does not work. The first checkpoint is written before any training. If the rate maps look like static and accuracy sits near 2%, check which index the notebook loaded before drawing any conclusion.

11Quick diagnosis

What you seeWhat it usually means
aP high, aGt at chanceMemorising the current observation, no structure learned. Check lg. If it is not falling, nothing is constraining path integration.
MaxHebb growing without limitMemory instability. Check the forget schedule and λ.
All accuracies flat at chance after 20kLearning rate or curriculum problem, not a modelling one. Confirm the scalings reached 1.0.
Long silence before step 1Normal. Graph compilation with graph_mode=True.
Rate maps look like noiseAlmost always an early checkpoint. Confirm the loaded iteration first.
aGinf equals aGt exactlyCommon early on, while inference has nothing to add over path integration. The two should separate as memory becomes useful.

12What this codebase is for

Everything above describes how TEM works. This section describes what the implementation is for, which is a different question, and the one that matters if you are deciding whether to reuse any of it elsewhere.

tem_tf2 is the reference implementation for the Tolman‑Eichenbaum Machine, and it exists to support one claim: that training a single generative model to predict the next sensory observation during random walks on graphs makes grid‑like and place‑like codes appear, and makes zero‑shot inference on unvisited edges possible. It is a scientific artifact packaged as runnable code. It is not a library, not a product, and not an agent.

What it optimises

One objective, cross‑entropy on the next observation, computed three ways. The full list is which_costs in parameters.py, and the arithmetic is in compute_losses.

LossPrediction it scores
lx_pFrom the full inferred place code
lx_gFrom path integration plus memory
lx_gtFrom path integration alone, before anything at this node has been seen
lp, lp_x, lgAgreement between the inferred and the generated codes
lg_reg, lp_regActivity regularisation, decayed away by the curriculum

lx_gt is the scientific payload. Everything else exists to make it work.

The contract

The network sees six things, assembled by inputs_2_tf:

x               one-hot observation       (seq_len, batch, s_size)
d               one-hot action taken      (seq_len, batch, n_actions)
g               grid state carried over   (batch, 120)
x_              filtered sensory state    (batch, 5 x 10)
hebb_mat        per-world memory          (batch, 400, 400)
hebb_mat_inv    per-world memory          (batch, 400, 400)

It returns the three predictions, plus an updated g, x_s, a_rnn and a_rnn_inv to carry into the next chunk.

The one fact that decides portability

The remaining fields in the input dictionary, positions, reward_pos, reward_val and s_visited, never reach the network. They only weight and mask the loss. The model has no notion of a node, a graph, a world or a reward. It sees an observation and an action. Anything that can produce those two streams can drive it.

Four layers, with different reuse value

LayerFilesHow reusable
Modeltem_model.py Environment‑agnostic, 1040 lines. Reusable unchanged.
Environmentsenvironments.py 1987 lines, nine world types. Each supplies adj, tran, states_mat, relation(), walk() and get_node_positions(). A new world is a subclass plus one if in initialise_environments.
Plumbingdata_utils.py, run_tem.py Batch‑slot lifecycle, memory zeroing, walk chunking, checkpoints. Tied to the design where a batch slot is a world.
Analysisbehaviour_analyses.py, plotting_functions.py, the notebook Rate maps, autocorrelograms, link‑inference curves. Regenerates the paper's figures, and is useful only if you are asking the paper's questions.

Assumptions baked in

These, rather than the code structure, decide whether the model ports to another domain.

What it is not

No policy, no value function, no reinforcement learning. No classifier and no supervised head of any kind. No action selection. No inference API for a trained model beyond running a forward pass over a fresh walk. No persistence of environments during training. Reward is a sensory class with a loss multiplier, not a signal.

Where to cut

If you wantTake
The representation tem_model.py unchanged, your own environment in place of environments.py, and your own readout on the rate map built by cell_norm_online or on a_rnn. The cheapest port.
The memory mechanism alone final_hebbian, attractor and hebb_scal_prod are self‑contained. Holding a and b through the chunk and folding them in at the end is reusable on its own.
A sequence model for a structured domain The whole architecture. Settle first whether your domain has composable actions. If it does not, TEM's central mechanism has nothing to do.
To reproduce or extend the paper All four layers unchanged, and the budget for the full run.