Nick Greenquist
← All posts
Projects

Recency Is All You Need: A 12-Model Experiment on User-History Representation for Two-Tower Retrieval in Recommenders

The textbook move in deep two-tower retrieval models — going back to YouTube’s DNN recommender (Covington et al., 2016) — is to pool a user’s interaction history into a single input vector. It’s the standard approach, and it buries possibly the most predictive signal there is: the last item the user interacted with.

TL;DR

I built a two-tower movie recommender and swapped its user tower twelve ways, each differing only in which slices of a user’s watch history feed the user vector — the whole history, the liked or disliked movies, the rating-weighted history, or just the most-recent item — trained them identically on MovieLens 32M, and measured retrieval (MRR). Three findings, and not what I assumed when starting this experiment:

  • Recency matters. Adding the single most-recently-watched movie to the standard full-history pool lifts MRR +22% — the biggest move in the study. One recent item nearly matches the entire summed history (§5).
  • Valence doesn’t help. The liked / disliked / rating-weighted channels that justify the production four-pool tower add nothing over a plain sum pool; the rating-weighted-only pool is the worst arm.
  • The case for a Transformer. The sharp drop-off in value past the last item is exactly why the field moved from pooling to sequential/Transformer architectures — the FMC → FPMC → SASRec ladder (§7).

Whole-corpus MRR for all 12 user-pooling arms, sorted and colour-coded by pool family

Twelve user towers, identical except for which history slices feed the user vector: adding the single last-watched item (orange) is a +22% MRR move, while the rating-valence channels (blue) sit on the full-pool baseline.


1. The setup: how do you turn a history into a vector?

A two-tower model scores a (user, item) pair by the dot product of two embeddings. The user tower is, at its most basic, the user’s interaction history. That history is a variable-length list of watched movies, and the tower has to fold it into one fixed-size vector. The default is a sum pool — look up an embedding per item, add, project:

Pooling an arbitrary-length watch history into one fixed user vector

Which pooling op you use seems to matter less than you’d think — here, sum and mean came out about even. The more interesting question is which history you pool. Instead of one bag, you can carve history into typed sub-histories — by rating sign (liked vs. disliked pools), by magnitude (weight each item by how far its rating sat above/below the user’s mean), or by recency (pull the most-recent item out as its own channel).

Adding a channel

Using the single last-watched movie’s embedding as its own channel is the simplest case: the model grabs that one item, looks up its embedding (no sum), and runs its own LayerNorm. That 32-d block is concatenated alongside the sum-pool block, and the combined vector goes through the one shared projection — concat first, project once:

Adding the last-watched item as its own 32-d block, concatenated before the shared projection

Because each block lands on its own columns of the projection’s input, the model can weight “what you just watched” differently from “everything you’ve watched.” Every arm in this study is just a different set of these blocks switched on.

2. The twelve arms

Each arm activates a different subset of pools; every active pool adds a 32-dim block, concatenated and projected → 256 → 128 → L2-normalize. Sum pools (full, liked, disliked, weighted) sum over all matching items into one vector. Single-item slots (last_liked, last_watched, second_to_last_watched) look up exactly one item — no sum — each with its own projection weights.

#ModelActive poolsDimWhat it isolates
1Only historyfull32the baseline single sum pool
2+ likesfull, liked64does a clean affinity channel beat lumping likes into full?
3+ dislikesfull, liked, disliked96does an explicit avoidance channel add subtractable signal?
4Weighted onlyweighted32can one rating-weighted pool carry it alone?
54-poolfull, liked, disliked, weighted128the full rating partition (= the production pooling block)
64-pool + last-liked… , last_liked160add immediate liked-session context on the 4-pool
7history + last-likedfull, last_liked64the minimal recency×valence test
8history + weightedfull, weighted64valence-weighting as an overlay on a dense pool
9last-liked onlylast_liked32the most-recent liked item, alone
10last-watched onlylast_watched32the most-recent item (any rating), alone — first-order Markov
11history + last-watchedfull, last_watched64recency channel on plain history — FPMC
12history + last-watched + 2nd-lastfull, last_watched, second_to_last_watched96does recency reach past the single last item?

Three families: the baseline (arm 1), the rating-valence arms (2/3/4/5/8), and the recency arms (6/7/9/10/11/12).

3. Results

Held-out rollback protocol: for each of 19,134 validation users, context = history so far, target = next watch, over n ≈ 382,138 examples. Numbers are full-corpus.

Whole-corpus retrieval, Δ vs arm 1:

#ModelActive poolsMRRRecall@10Recall@50NDCG@10MRRΔ
1Only historyfull0.11330.21890.45900.1270
2+ likesfull,liked0.11410.22080.46020.1280+0.0008
3+ dislikesfull,liked,disliked0.11330.22040.45930.12740.0000
4Weighted onlyweighted0.07080.14760.34840.0790−0.0425
54-poolfull,liked,disliked,weighted0.11320.22170.46130.1276−0.0001
64-pool + last-liked…,last_liked0.12270.24170.49380.1391+0.0094
7history + last-likedfull,last_liked0.12360.24310.49360.1402+0.0103
8history + weightedfull,weighted0.11300.21990.45960.1270−0.0003
9last-liked onlylast_liked0.08050.16880.36550.0917−0.0328
10last-watched onlylast_watched0.10990.21000.40660.1241−0.0034
11history + last-watchedfull,last_watched0.13860.26370.51290.1566+0.0253
12history + last-watched + 2nd-lastfull,last_watched,second_to_last_watched0.14310.27240.52740.1619+0.0298

4. Results by popularity tier

Does pooling help more on popular movies or the long tail? MRR split by target-movie popularity:

ArmWhole (382,138)HEAD >1k (369,486)Q4 popular (343,906)Q3 mid (26,923)TAIL ≤1k (12,652)
1 full0.11330.11710.12470.01420.0032
2 +liked0.11410.11790.12550.01400.0033
3 +disliked0.11330.11710.12470.01470.0031
4 weighted0.07080.07320.07820.00510.0015
5 4-pool0.11320.11690.12450.01520.0036
6 +lastL0.12270.12680.13480.01880.0044
7 full+lastL0.12360.12770.13580.01820.0046
8 full+wt0.11300.11680.12440.01330.0029
9 lastL-only0.08050.08310.08810.01520.0070
10 lastW-only0.10990.11320.11980.02510.0128
11 full+lastW0.13860.14310.15200.02320.0061
12 full+lastW+2nd0.14310.14780.15680.02510.0070

5. What the numbers say

  • Recency is the lever, one item deep. Adding the last watch (arm 11) beats the plain pool +22%; the 2nd-to-last (arm 12) adds nothing past noise. The signal is first-order.
  • One recent item ≈ the whole summed history. last_watched alone (arm 10) lands within 3% of the full pool (arm 1) — proof the sum is lossy: ~50 items collapsed to one point, no identity, no order.
  • Recency beats valence. last_watched > last_liked on every tier. The “did they like it?” filter drops the immediately-preceding watch — the most predictive item there is.
  • Rating channels add nothing. Liked/disliked/weighted splits (arms 2/3/5/8) sit on the plain pool; weighted-only (arm 4, 0.0708) is the worst arm — debiasing starves the gradient.
  • Head vs tail split the prize. Recency arms win the head (arm 12 tops every head tier); on the tail, last_watched alone (arm 10) wins — no full-pool popularity bias.

6. Honest caveats

  • The eval probably overstates recency. We’re predicting a user’s next movie, and the winning arms get to see the one they just watched. On MovieLens, back-to-back ratings are often from the same sitting, so the last watch is an unusually strong hint. Some of the +22% is just that.
  • This is dataset-specific. Recency dominates on MovieLens, where consumption is sequential and session-correlated. On your problem — different catalog, weaker session structure, longer gaps between events — the last item may carry far less, and rating valence may carry more. Treat “recency is the lever” as a hypothesis to re-test, not a law.

7. Takeaway

If you’re folding a history into one vector for two-tower retrieval, recency may well matter more than rating valence — it certainly did here. The single last-watched item is worth more than the entire rest of the history summed together (+22% MRR as its own channel), while the liked / disliked / weighted machinery that feels principled adds nothing on top of a plain sum pool.

None of this is new ground — the field walked it years ago, and the arms here happen to retrace its steps. They climb the FMC → FPMC → SASRec ladder of sequential recommendation: last_watched alone (arm 10) is a first-order Markov chain (FMC); bolt it onto the full pool (arm 11) and you have FPMC (Factorizing Personalized Markov Chains — a personalized history pool plus a last-item transition term); and arm 12’s hand-built second-order term barely moves. Hand-coding recency runs out by the second item. That’s the argument for a model that learns it instead: self-attention over the sequence, i.e. SASRec (a Transformer that weights which past items matter).


Appendix

  • Architecture — two-tower model, ID-only item tower (BASE_TOWERS=idonly FEATURE_TOWERS=none); only USER_POOLS varies across arms.
  • Training — identical for every arm: seed 42, 200k steps, LR 0.001 (cosine → 1e-4), batch 512, softmax temperature 0.1, α = 0. Pool signals come from the cached softmax training tensor, so no data was rebuilt between arms.
  • Evaluation — held-out rollback protocol over all 19,134 validation users (n ≈ 382,138); Recall / Hit / NDCG / MRR @ K ∈ {1, 5, 10, 20, 50, 100, 150, 200, 250} plus the popularity-tier split.

Background sources.

← All posts