Recommender Systems

Sequence-aware recommendation

What you did five minutes ago predicts your next click far better than what you did last year, so sequence-aware models read your actions in order instead of treating them as an unordered pile.

On this page 9
  1. The short answer
  2. The analogy you have already lived
  3. Why it exists
  4. How the simplest version works
  5. Where the simple version breaks
  6. Where you have seen this
  7. The honest part
  8. Remember this
  9. What to learn next

One lesson, three depths. Pick the one that fits you today — you can switch any time.

Beginner — No maths. Plain English.

The short answer

Sequence-aware recommendation reads your recent actions in the order they happened, because order carries information that a pile of items throws away.

The analogy you have already lived

You buy a phone. The next day, the shopping app shows you phone covers, screen guards and chargers. That is right, and it feels natural.

Now flip it. You buy a phone cover. Does the app then show you a phone? Never. Nobody buys the cover first.

Both events involve the same two items. What separates them is which came first. A model that only knows "these two things were bought by the same person" cannot tell the difference between those two situations. A model that reads the order can.

Why it exists

Everything so far in this section treats a person as a bag of items. Aarav liked these five films, so he is those five films, in no particular order.

That works for slow-changing taste. It falls apart for three common situations.

Sessions. Somebody browsing right now has an intention. They are shopping for a gift, or planning a trip, or looking for one song to finish a playlist. That intention lives in the last ten minutes, not in the last two years.

Steps that follow steps. Phone then case. Flight then hotel. Camera then memory card. Some items only make sense after another one.

Anonymous visitors. A large share of traffic on many sites has no account and no history. All you have is what they clicked since they arrived. Bag-of-items methods have nothing to work with; the current session is everything.

How the simplest version works

Count how often item B was viewed immediately after item A. That is it. That is a transition count, and it already captures direction.

   sessions seen in the log:

     phone  ->  case  ->  screen-guard
     phone  ->  case  ->  charger
     phone  ->  screen-guard  ->  case
     phone  ->  charger

   counting only next-door pairs:

     after phone :  case (3),  charger (1),  screen-guard (1)
     after case  :  screen-guard (2),  charger (1)
     after charger: earphones (1)

Now "what comes after phone" has an answer, and it is different from "what comes after case". An order-blind method sees only that phone and case appeared together, four times, with no direction attached.

Where the simple version breaks

Suppose the last thing somebody looked at was a charger. What comes next?

If they were looking at a laptop before that, a laptop stand is a good guess. If they were looking at a phone, earphones are a better guess.

A model that only remembers the last item cannot see the difference. It gives the same answer to both people, and it is half wrong every time.

So the whole history of this field is about remembering more of the sequence, without needing an impossible number of counts. Counting pairs is easy. Counting every possible pair of pairs is not, and counting every possible sequence of five items is hopeless.

The modern answer borrows the machinery of language models: attention. It lets the model look back across the whole session and weigh which earlier items matter now. If you have read that lesson, you already know most of how a modern sequence recommender works.

Where you have seen this

  • YouTube autoplay, which follows the video you finished rather than your lifetime history.
  • A shopping app showing accessories the moment you buy the main product.
  • Spotify's radio, which drifts as the session goes on.
  • A food app noticing you are ordering at 11 pm, not at noon.

The honest part

Recent actions are strong signal and short memory. A person shopping for a gift for somebody else generates a session that says nothing about their own taste. A model that leans hard on recency will spend the next week recommending children's toys to a person with no children.

There is a second trap, and it catches almost everybody. It is very easy to evaluate these models wrongly by accident. Hide a random click from the middle of a session and train on the rest. The model can now see the future. The clicks that came after the hidden one are sitting in its training data.

The score looks wonderful and means nothing.

Split by time. Train on everything before a date, test on everything after. It gives worse-looking numbers and truthful ones.

Remember this

  • Order carries information that a bag of items throws away.
  • Counting next-door pairs is the simplest version, and it already captures direction.
  • Evaluate by splitting on time, or you are testing a model that can see the future.

What to learn next

Developer — Code and libraries.

Setup

bash
python3 --version

Standard library only. Twelve short sessions, and the two failure modes of sequence modelling both visible in the output.

Order-aware against order-blind

sequences.py
from collections import Counter, defaultdict

# Each session is one visit, in the order things were viewed.
SESSIONS = [
    ["phone", "case", "screen-guard"],
    ["phone", "case", "charger"],
    ["phone", "screen-guard", "case"],
    ["phone", "charger", "earphones"],
    ["phone", "case"],
    ["laptop", "laptop-bag", "mouse"],
    ["laptop", "mouse", "laptop-bag"],
    ["laptop", "charger", "laptop-stand"],
    ["laptop", "laptop-bag"],
    ["shoes", "socks", "shoe-polish"],
    ["shoes", "socks"],
    ["case", "screen-guard"],
]

# Order-aware: count only the pairs that happened one after the other.
NEXT = defaultdict(Counter)
for session in SESSIONS:
    for a, b in zip(session, session[1:]):
        NEXT[a][b] += 1

# Order-blind: count any two items appearing in the same session, in either direction.
TOGETHER = defaultdict(Counter)
for session in SESSIONS:
    for a in session:
        for b in session:
            if a != b:
                TOGETHER[a][b] += 1


def next_probs(item):
    total = sum(NEXT[item].values())
    return {b: n / total for b, n in NEXT[item].items()} if total else {}


print("order matters, and the counts prove it:")
for a, b in [("phone", "case"), ("case", "phone"), ("laptop", "mouse"), ("mouse", "laptop")]:
    p = next_probs(a).get(b, 0.0)
    print(f"  P(next = {b:<12} | last = {a:<7}) = {p:.2f}"
          f"        seen together either way: {TOGETHER[a][b]}")

print("\nwhat the order-aware model predicts next:")
for item in ["phone", "case", "laptop", "shoes"]:
    ranked = sorted(next_probs(item).items(), key=lambda kv: (-kv[1], kv[0]))
    print(f"  after {item:<8} " + "  ".join(f"{b} {p:.2f}" for b, p in ranked[:3]))

print("\nthe problem with looking at only the last item:")
ranked = sorted(next_probs("charger").items(), key=lambda kv: (-kv[1], kv[0]))
print("  after charger  " + "  ".join(f"{b} {p:.2f}" for b, p in ranked))
for context in (["phone", "charger"], ["laptop", "charger"]):
    print(f"  but the session so far was {context} - the model never looked at position 1")
Output
order matters, and the counts prove it:
  P(next = case         | last = phone  ) = 0.60        seen together either way: 4
  P(next = phone        | last = case   ) = 0.00        seen together either way: 4
  P(next = mouse        | last = laptop ) = 0.25        seen together either way: 2
  P(next = laptop       | last = mouse  ) = 0.00        seen together either way: 2

what the order-aware model predicts next:
  after phone    case 0.60  charger 0.20  screen-guard 0.20
  after case     screen-guard 0.67  charger 0.33
  after laptop   laptop-bag 0.50  charger 0.25  mouse 0.25
  after shoes    socks 1.00

the problem with looking at only the last item:
  after charger  earphones 0.50  laptop-stand 0.50
  but the session so far was ['phone', 'charger'] - the model never looked at position 1
  but the session so far was ['laptop', 'charger'] - the model never looked at position 1

The first block is the entire argument for this lesson

P(case | phone) is 0.60. P(phone | case) is 0.00.

The right-hand column shows both directions were "seen together" four times. An order-blind co-occurrence count is symmetric by construction — it is the same number read from either end — so no amount of tuning lets it express the difference between those two probabilities.

The same holds for laptop and mouse: 0.25 one way, 0.00 the other, from an identical co-occurrence count of 2.

The last block is the limit of the simple method

after charger: earphones 0.50, laptop-stand 0.50. A perfect tie, and the model has no way to break it.

But the tie is not real. One of those sessions started with a phone and one started with a laptop, and the first item of the session settles the question completely. A first-order model threw that away when it looked only at the previous step.

Fixing this by counting pairs-of-pairs does not scale: with n items there are n² possible two-item contexts, and the counts get thinner as the context gets longer. That tension — longer memory against sparser counts — is why the field moved to learned models, and eventually to attention, which reads the whole session without needing a count per context.

Line by line, for the parts that are not obvious

zip(session, session[1:]) — the standard adjacent-pairs idiom. For [a, b, c] it yields (a, b) and (b, c). It handles a one-item session correctly by yielding nothing, which saves a boundary check.

defaultdict(Counter) — a missing key returns an empty Counter instead of raising. Careful: reading NEXT["unknown"] inserts the key. In a long-running service that is a slow memory leak, so use NEXT.get(item, Counter()) on a read path.

if total else {} — the last item of a session has no successor. Returning an empty dict rather than dividing by zero is the whole guard, and it fires on every session's final item.

The TOGETHER double loop — O(len(session)²) per session. Harmless on three-item sessions, quadratic on a session with 400 events. Real implementations cap the window.

Common mistakes

Evaluating with a random split. Hide a click from the middle of a session and the events after it are still in training. The model sees the future. This is the single most common flaw in sequence-recommendation results, and it inflates scores dramatically. Split on time: train on everything before a cutoff, test after.

Ignoring repeat consumption. In music, groceries and podcasts, the most likely next item is frequently one the user has already consumed. Blindly filtering out seen items destroys accuracy in those domains. In films and books, the opposite is true. Check your domain before writing the filter.

Letting one long session dominate. A bot or an idle tab produces a session with thousands of events. Cap sequence length and drop sessions above a sanity threshold before they distort the transition counts.

No session boundary. Events from Monday and events from Thursday are not one sequence. Cut a new session after 30 minutes of inactivity, which is the usual convention, and check the number against your own data.

Assuming a deep model is required. Ludewig and Jannach (2018) evaluated session-based algorithms carefully and found simple session-kNN methods competitive with, and often better than, the neural models published at the time. Build the counting version first, and make the neural version beat it.

Try it yourself

Add a SECOND dictionary keyed by the pair (previous, last) rather than by the last item alone. Recount, and confirm that ("phone", "charger") now predicts earphones while ("laptop", "charger") predicts laptop-stand.

Then count the distinct keys in each. NEXT has 9, SECOND has 7 — fewer, because these sessions are too short to produce many distinct pairs.

That is the trap, and it is worth sitting with. The number of possible contexts grew from |I| to |I|², while the amount of data stayed exactly the same. Every context is now estimated from fewer observations than before. Extrapolate to a catalogue of 100,000 items and you have derived, from your own twelve sessions, why the field stopped counting and started learning.

What to learn next

Researcher — Mathematics and papers.

Problem statement

Given a sequence $S_u = (i_1, i_2, \dots, i_t)$ of items consumed by user $u$ in order, estimate

$$ P(i_{t+1} = j \mid i_1, \dots, i_t) $$

The design question is how much of the prefix to condition on, and how to represent it.

The progression

First-order Markov chain. $P(i_{t+1} \mid i_t)$, estimated by transition counts. $O(|I|^2)$ parameters, and the estimate is unusable for rare items. This is the code above.

FPMC (Rendle, Freudenthaler and Schmidt-Thieme, 2010), Factorizing Personalized Markov Chains, factorises a three-way tensor over (user, previous item, next item), giving

$$ \hat{x}_{u,i_t,j} = \mathbf{p}_u^\top \mathbf{q}j + \mathbf{m}{i_t}^\top \mathbf{n}_j $$

The first term is long-term taste, the second is the sequential transition, and factorisation shares statistical strength across items so rare transitions are still estimated. This decomposition — a persistent term plus a sequential term — recurs in every later model.

GRU4Rec (Hidasi et al., 2016) encodes the session with a recurrent network, session-parallel mini-batches, and a ranking loss (BPR-max or TOP1-max). It made session-based deep models practical and introduced the training tricks that mattered more than the architecture.

SASRec (Kang and McAuley, 2018) replaces recurrence with a causally masked self-attention stack. Each position attends to all earlier positions, so long-range dependence needs no recurrent state, and training parallelises across positions.

$$ \text{Attention}(Q,K,V) = \mathrm{softmax}!\left(\frac{QK^\top}{\sqrt{d}} + M\right)V $$

with $M_{ab} = -\infty$ for $b > a$, enforcing that position $a$ cannot see the future. See attention and transformers.

BERT4Rec (Sun et al., 2019) drops the causal mask and trains with a cloze objective: mask random positions and predict them from both sides. The bidirectional context is richer, and inference requires appending a mask token to predict the next item.

The BERT4Rec correction

BERT4Rec was reported to beat SASRec on standard benchmarks, and that comparison did not hold up.

Petrov and Macdonald (2022), A Systematic Review and Replicability Study of BERT4Rec, found the published gains reproduce only with far longer training than the original comparisons allowed. Klenitskiy and Vasilev (2023), Turning Dross Into Gold Loss, showed that training SASRec with full cross-entropy over the catalogue — rather than the sampled loss used in the original paper — matches or exceeds BERT4Rec while training faster.

The transferable lesson is not about these two models. It is that the loss and the negative-sampling scheme frequently account for more of a reported gain than the architecture does, and papers rarely control for them.

Cost

ModelTraining cost per sequenceInferenceMemory
Markovcounting, $O(n)$$O(1)$ lookup$O(\lvert I \rvert^2)$ worst case
FPMC$O(nk)$$O(k)$$O((\lvert U \rvert + \lvert I \rvert)k)$
GRU4Rec$O(nd^2)$, sequential in $n$$O(d^2)$ per step$O(d^2)$
SASRec$O(n^2 d + n d^2)$, parallel in $n$$O(n^2 d)$$O(n^2)$ attention

$n$ is sequence length, $d$ the model width, $k$ the factor dimension. The quadratic term is why production systems truncate sequences to the last 50 to 200 events, a truncation that is usually harmless because attention weight concentrates on recent positions anyway.

Evaluation, and the leakage that invalidates it

Three protocols appear in the literature, and they are not interchangeable.

Leave-one-out with a random held-out item — leaks future information into training. Avoid.

Leave-one-out with the last item of each sequence — the most common protocol. Still leaks at the population level: user A's test interaction may precede user B's training interactions in wall-clock time, so the model trains on events from after the moment it is being tested at.

Global temporal split — a single wall-clock cutoff for everybody. The only protocol without leakage, and it produces lower numbers, which is part of why it is less popular.

Ji et al. (2023), A Critical Study on Data Leakage in Recommender System Offline Evaluation, quantified the gap across models and datasets and found the leakage-free protocol changes model rankings, not only their scores.

Add to this the sampled-metrics problem from ranking metrics — Krichene and Rendle (2020) — and a large fraction of published sequence-recommendation comparisons are not directly usable.

Papers

  • Rendle, Freudenthaler and Schmidt-Thieme (2010), Factorizing Personalized Markov Chains for Next-Basket Recommendation, WWW.
  • Hidasi et al. (2016), Session-based Recommendations with Recurrent Neural Networks, ICLR — arxiv.org/abs/1511.06939
  • Ludewig and Jannach (2018), Evaluation of Session-based Recommendation Algorithms — arxiv.org/abs/1803.09587
  • Kang and McAuley (2018), Self-Attentive Sequential Recommendation (SASRec) — arxiv.org/abs/1808.09781
  • Sun et al. (2019), BERT4Rec — arxiv.org/abs/1904.06690
  • Petrov and Macdonald (2022), A Systematic Review and Replicability Study of BERT4Rec, RecSys — arxiv.org/abs/2207.07483
  • Klenitskiy and Vasilev (2023), Turning Dross Into Gold Loss, RecSys.
  • Ji et al. (2023), A Critical Study on Data Leakage in Recommender System Offline Evaluation, ACM TOIS.

What to learn next