RecSys · textbook
Trainer Widgets Revision About All chapters ← Retrieval Scoring →

Supplement · The X algorithm · page 5 of 11

Ranking: the transformer that forbids candidates to look at each other

Retrieval has selected hundreds of posts — now they have to be ordered. Let us go through the Phoenix ranking model: how the history and the candidates are laid into one sequence, what exactly the attention mask does, why there are many predictions and how they are trained when the data for different heads comes from different places.

In brief

  • One sequence for everything: two profile tokens, up to 1022 history events and 64 candidates in a row. Not «a model on a user-post pair» but one pass over the whole slate.
  • The isolation mask is one line in the attention kernel. A query looks at a key only if the key is from the history or the key is the query itself. Candidates physically do not see each other.
  • The ranker is bigger than the retriever: a dimension of 2560 against 1024, 20 query heads against 16. There are hundreds of candidates now, not 28 million — a more expensive model can be afforded.
  • The heads are trained on different subsamples. The conversion ones only on clicked posts, the negative-feedback heads only on real impressions. This is that same fight against sample selection bias as in ESMM.
  • Besides probabilities the model predicts continuous quantities — dwell time and active seconds. That is regression, not classification, and it enters the score as a term of its own.

1. How everything is laid into one sequence

The first thing to get straight: this is not a model of the kind «feed in a (user, post) pair — get a number». One pass processes the whole set of candidates at once, and it is laid out like this:

profile 2 tokens the interaction history up to 1022 events: post + author + action + context candidates 64 posts that have to be scored 1024 = 2 + 1022 — the power of two is no accident: the attention kernel's tiles are multiples of 128 outputs only from the candidate positions each → a vector of action probabilities + continuous ones History positions are encoded «anchored to the right edge»: fresh events are always next to the candidates, no matter how much history there is.
Profile, history and candidates — a single sequence. The logits are taken only from the candidate positions.

Such a layout is a direct continuation of what we discussed in the chapter «Transformers over history»: the user's history as a sequence, a candidate as one more token, attention instead of hand-made aggregates. The difference is that here there are 64 candidates at once, and that is exactly what gives rise to the main question of this page.

Anchoring the positions to the right edge

The parameter right_anchored_rope: True means that the positional codes are counted not from the beginning of the history but from its end. What for: people's histories differ in length — one has 30 events, another 1022. With ordinary numbering «from the beginning» the last event of the first would land at position 30 and of the second at 1022, and the model would have to learn separately what position means «recent» here.

With anchoring to the right edge the last event always has the same position relative to the candidates. The model learns one dependence «freshness → importance» instead of a thousand variants.

2. The isolation mask: one line that determines the whole system

On the overview page we mentioned that candidates do not see each other. Now let us look at how that is written down. Every token is assigned a segment:

HISTORY_SEGMENT_ID = 1
CANDIDATE_SEGMENT_ID = -1
PADDING_SEGMENT_ID = 0

segment_ids = jnp.where(padding_mask,
                        jnp.where(idx >= candidate_start_offset, -1, 1),
                        0)

And inside the attention kernel stands this:

mask = jnp.logical_or(seg_k == HISTORY_SEGMENT_ID, span_q[:, None] == span_k[None, :])

A fragment of ranker_attention_v2.py · code by X, Apache 2.0, commit 28e414f

Let us read it slowly. A query at position \(q\) may look at a key at position \(k\) if and only if at least one of two things holds:

That is all. No other options. Which means:

QuerySeesDoes not see
a history tokenthe whole history and the profilethe candidates
a candidatethe whole history, the profile, itselfthe other candidates

Note: the history is bidirectional. There is no causal mask by default, and an event from the middle of the history looks calmly at a later one. This is not a language model predicting the next token; it is an encoder, and there is no reason to forbid it to look forward along the history.

How this is implemented at the level of the computation

Formally the mask could simply be built as a matrix and multiplied in. But it is sparse and structured, so it was sewn into the attention kernel itself. In the code, two loops are run for every block of queries:

acc, m_i, l_i = lax.fori_loop(0, history_upper_bound, body, (acc, m_i, l_i))

candidate_lower_bound = jnp.maximum(history_upper_bound, offset_q // block_k)
candidate_upper_bound = (offset_q + block_q - 1) // block_k + 1
acc, m_i, l_i = lax.fori_loop(candidate_lower_bound, candidate_upper_bound, body, (acc, m_i, l_i))

A fragment of ranker_attention_v2.py · code by X, Apache 2.0, commit 28e414f

The first loop goes over all the blocks of the history. The second only over the blocks that intersect the query block itself, that is, along the diagonal. The «candidate × another candidate» blocks are not loaded into memory at all.

That matters not only for cleanliness: attention is quadratic in length, and 64 candidates looking at each other means \(64^2\) extra products for every layer and every head. The isolation here saves computation, not only guarantees consistency.

What to look for here
  1. The matrix is drawn exactly by the line from the kernel: the blue cells are a key from the history, the yellow ones «itself». The candidate rows are empty everywhere except the history and the diagonal.
  2. Press «swap the batch neighbours» with the isolation on: the score of candidate 1 does not change in a single digit. The discrepancy is exactly zero — not «small» but zero by construction.
  3. Now switch the isolation off. Pink cells appear — links between candidates. Swap the neighbours again: the score moves. In our example from 0.012 to −0.269.
  4. Count the permitted cells: the isolation removes a noticeable share of them, and that is a direct saving of computation.

What to say in an interview: «The isolation of candidates makes the score a function of the (user, post) pair alone. Hence cacheability, reproducibility and explainability — at the price of the model not seeing the slate and not being able to bring diversity by itself».

What is paid for this

The trade-off is worth stating honestly, because in an interview it is precisely the flip side that is usually asked about.

A model that sees the whole slate is fundamentally stronger. It can notice that three posts in a row are about the same thing, that the second post duplicates the first, that after a long video a short text should be put. That is the listwise formulation, and it gives better quality all else being equal.

Having given it up, the system gets four things:

  1. Cacheability. The score of a (user, post) pair can be saved and reused on the next request. In the code of the feed there is a separate source of cached ranked posts — it exists precisely thanks to this property.
  2. Reproducibility. Two identical requests give the same order. Without the isolation the order would depend on how the posts were split into batches.
  3. Explainability. The question «why did this post end up lower» has an answer that does not contain the words «because another post happened to be next to it».
  4. Speed. The quadratic term in the candidates disappears.

And the listwise effects are brought back later and separately — by re-ranking through a determinantal process, which we discussed on the scoring page. The result is a division of labour: the model answers «how good is this post for this person», and a separate service «how do these posts look together».

3. The heads: what exactly is predicted

Two sets of numbers are taken from every candidate position.

The discrete heads are the probabilities of actions: a like, a reply, a repost, a quote, a click, expanding a photo, opening a video, following the author, a report, a block, hiding, «not interested» and so on. The full list and the weights they are combined with are on the scoring page.

The continuous heads are quantities that have no «happened or not»: the dwell time on a post, the time after a click, the active seconds. That is regression. There are eight of them in the config.

Why time cannot be forced into classification

The temptation: declare «dwelled longer than N seconds» a binary event and predict its probability. People do that, and it even works, but the main thing is lost — exactly how much. The difference between 3 and 30 seconds disappears if the threshold stands at five.

Regression preserves that difference but brings troubles of its own: the distribution of time is heavy-tailed, outliers pull the mean, and the scale of the target has to be reconciled with the scale of the other terms of the score. In the config you can see that the metrics for the continuous targets are computed through the mean absolute error (continuous_metrics_mae_mean) — a measure robust to outliers instead of a quadratic one.

In the weights the dwell time has a coefficient of 0.004 — small precisely because the quantity is measured in seconds and without scaling would drown every probability.

4. The most interesting part: the heads learn on different subsamples

Here begins what makes someone else's production code worth reading. Naively it seems that all the heads are trained on the same examples. In reality it is decided separately for every head whether this example is informative.

Conversions — only on clicked posts

if self.config.condition_conversion_on_click:
    has_click = targets[:, :, CLICK_ACTION_INDEX]
    no_click = 1 - has_click
    conv_head_mask = jnp.zeros(num_actions).at[jnp.array(CLICK_CONDITIONED_ACTION_INDICES)].set(1.0)
    conv_zero_mask = no_click[:, :, None] * conv_head_mask
    loss_mask = loss_mask * (1 - conv_zero_mask)

A fragment of recsys_model.py · code by X, Apache 2.0, commit 28e414f

It reads like this: if there was no click on the post, then the loss of the conversion heads on this example is zeroed out. They get no gradient at all.

The logic is ironclad. What does «the conversion did not happen» mean for a post that was not clicked? Nothing. The user did not refuse the action — they simply had no opportunity to perform it. Training a head on such an example means teaching it that «no opportunity» is «a refusal».

This is exactly the sample selection bias for the sake of which ESMM was invented in the chapter «ESMM and sample selection bias»: there it was shown that a conversion model trained only on clicks makes systematic errors when rolled out over all impressions. There are two solutions — either model the whole chain through a product of probabilities, or honestly restrict the training sample. Here the second path is chosen, but with an important addition: the training is joint, in one model, so the shared body still sees all the examples and only the head specialises.

Negative feedback — only on real impressions

if self.config.mask_neg_feedback_on_negatives:
    neg_head_mask = jnp.zeros(num_actions).at[jnp.array(NEGATIVE_FEEDBACK_HEAD_INDICES)].set(1.0)
    zero_mask = negative_sample_mask[:, :, None] * neg_head_mask
    loss_mask = loss_mask * (1 - zero_mask)

A fragment of recsys_model.py · code by X, Apache 2.0, commit 28e414f

Here the same idea from the other side. Sampled negatives are added to the training set — posts that were never shown to the user. For heads like «will they like it» that is fine: it was not shown, so they almost certainly would not have liked it.

But for the head «will they report it» such an example is poisonous. The absence of a report on an unshown post is not evidence that there would have been no report. Having trained on that, we get a model convinced that reports are rare — and it will be statistically right and practically useless.

So the negative-feedback heads are not trained on sampled negatives at all. We discussed exactly this trap in the chapter «The problems of implicit data»: the absence of a signal and a negative signal are different things, and confusing them is expensive.

One more rule: a negative cancels a positive

Before all the masks, this is done:

zero_mask = has_negative_feedback_action_expanded & non_negative_feedback_action_mask
targets_for_loss = jnp.where(zero_mask, False, targets_for_loss)

A fragment of recsys_model.py · code by X, Apache 2.0, commit 28e414f

If there was a negative reaction on a post, then all the positive labels on it are zeroed out. Liked it and immediately reported it — for the positive heads that is no longer a positive.

We saw the same principle in retrieval, where a positive counted as a like in the absence of any negative. Here it is applied at the level of the labels. The practical meaning: do not teach the system to fetch clickbait, which collects a reaction and immediately provokes rejection.

5. Features: what the model knows about a post and about the moment

The FeaturePrepConfig block in the config is essentially a list of everything fed to the model besides the history and the identifiers.

GroupFeaturesWhat for
The viewercountry, language, location, gender, age, installed apps, IPCoarse personalisation where there is little history
The momenthour of the day, time zone, product surfaceIn the morning and in the evening a person reads different things
The postthe age of the post, engagement counters, the semantic IDFreshness and the accumulated response
The relationwhether the viewer follows the author, whether the author follows the viewerA mutual follow is a strong signal of acquaintance
Behaviourdwell timeUsed both as a feature of the history and as a target

Two rows deserve a separate look.

Dithering the hour of the day
hour_of_day_dither_fraction: 0.1

A fragment of xrecsys.py · code by X, Apache 2.0, commit 28e414f

The hour of the day is fed in with a ten-percent random jitter. Why spoil a feature with noise?

Because an hour is an artificially discrete quantity. The difference between 13:59 and 14:01 is nil for a person, while for the feature it is a transition between buckets. Without dithering the model learns sharp boundaries on round hours — an artefact of the encoding, not a property of behaviour. The jitter blurs the boundaries and forces a smooth dependence to be learned.

This is the same motive as in piecewise-linear encoding: there continuity was achieved by the construction of the encoding, here by noise. The trick is cruder, but it applies to any feature without changing the architecture.

The age of a post with a granularity of an hour
post_age_granularity_mins: 60

A fragment of xrecsys.py · code by X, Apache 2.0, commit 28e414f

The age of a post is rounded to the hour. The reason is practical: without rounding, age is a continuously growing number, and one and the same post has different features in two neighbouring requests. That breaks the caching of the score discussed above: a cached value goes stale immediately.

With rounding to the hour the score stays valid within the hour. The model loses a resolution it hardly needs — the difference between a post aged 3 hours 10 minutes and 3 hours 50 minutes is negligible — and in exchange gets a cache that lives.

6. Training

There are several decisions here, and each deserves an explanation.

Two optimisers for one model

optim_config=RecsysDenseOptimConfig(
    optim="muon",
    muon_consistent_rms=0.2,
    muon_matrix_weight_decay=0.014,
    b1=0.95, b2=0.98,
),
emb_optim_config=RecsysEmbeddingOptimConfig(
    rowwise_adagrad=RecsysRowwiseAdagradConfig(
        learning_rate=0.28, half_life_steps=2500, lazy_decay=True, weight_decay=2.8e-4,
    ),
),

A fragment of xrecsys.py · code by X, Apache 2.0, commit 28e414f

The dense layers are trained with Muon, the embedding tables with row-wise Adagrad. That is not eclecticism but a consequence of their fundamentally different gradient structure.

The parameter half_life_steps: 2500 sets the half-life of Adagrad's accumulated statistics: without forgetting, the denominator grows monotonically, the step goes to zero and old rows stop learning. With forgetting the model stays able to adapt to changed behaviour — a direct answer to data drift.

And lazy_decay: True means the forgetting is applied to a row lazily, at the moment of its next update, rather than to the whole table on every step. For a table of millions of rows that is the difference between «expensive» and «free».

Packing the sequences

People's histories are of different lengths, while a tensor is rectangular. The naive solution — pad everyone up to the maximum — means that at an average length of 510 and a maximum of 1022, half the computation goes on padding.

In the config use_seqpack is on, with a Beta distribution of lengths from 126 to 1022 with a mean of 510. Several short histories are laid into one physical row, and they are separated by those same segment identifiers we looked at in the mask. Attention does not let them flow into each other.

An elegant detail: the mechanism of segments, introduced for the sake of isolating the candidates, solved the packing task for free as well. One and the same field serves two purposes.

The LogQ correction here too

The ranking config has log_q_correction: True. That may look strange: the correction was discussed in the context of retrieval, where the negatives are sampled from the batch. But the training of the ranker also has sampled negatives — posts that were not shown to the user, added so that the model sees more than what was served.

They are not sampled uniformly, and without a correction the ranker would inherit the same bias towards the popular. The mechanics are the same as those discussed in detail on the retrieval page and in the LogQ widget.

7. The ranker's configuration against the retriever's

ParameterRetrievalRankingWhy the difference
dimension10242560The ranker works with hundreds of candidates, not with 28 million — it can afford more
layers88The depth is the same, the width grows
query / key heads16 / 420 / 4More query heads at the same key cache
history10231022 + 2 profile tokens1024 in total — a multiple of the kernel's tiles
candidates6464
normalisation of queries and keysoffonThe ranker is deeper in effective width, stability matters more
cap on the attention logits80.0offThe role of the stabiliser was taken over by the normalisation
attention between semantic ID levelsonoffThe ranker has a rich context even without it
learning rate2e-37.1e-4The model is bigger — the step is smaller

The row about the normalisation of queries and keys deserves a comment. qk_norm normalises the vectors before the dot product is computed, bounding the magnitude of the attention logits from above. The alternative is to clip the logits hard, which is what the retriever does with the parameter 80.0. Both tricks cure one disease: diverging attention, where one logit runs away, the softmax collapses into a delta function and the gradient dies. In the ranker the softer of the two was chosen.

Common mistakes and hidden rocks

What people trip over
  • Thinking that the isolation of candidates is about saving computation. There is a saving, but the main thing is consistency: the score becomes a function of the (user, post) pair alone, and only because of that can it be cached and reproduced.
  • Thinking that all the heads learn on the same examples. The conversion ones only on clicked posts, the negative-feedback heads only on real impressions. The loss mask decides that separately for every head.
  • Confusing «no signal» with «a negative signal». The absence of a report on an unshown post does not mean there would have been no report. That is exactly why such examples are excluded from the training of those heads.
  • Believing the history is causally masked. It is bidirectional: this is an encoder, not a language model. A causal mask here would be a superfluous restriction.
  • Not noticing that rounding the age of a post is about the cache. The granularity of an hour is needed not for accuracy but so that a cached score does not go stale every minute.
  • Thinking that one optimiser for the whole model is the norm. The update frequency of dense layers and of embedding tables differs by orders of magnitude, and one learning rate for all will either strangle the embeddings or blow up the dense part.

Interview questions

How is the isolation of candidates implemented and what does it give?

By one condition in the attention kernel: a query sees a key only if the key belongs to the history or the key is the query itself. Candidates physically cannot look at each other; the «candidate × another candidate» blocks are not even loaded into the computation.

It gives four things. Cacheability: the score depends only on the (user, post) pair. Reproducibility: the order does not depend on the split into batches. Explainability: the reason for a position does not contain the words «another post happened to be next to it». Speed: the quadratic term in the candidates disappears.

The price is that the model does not see the slate and cannot account for the mutual influence of posts. That task is moved into a separate re-ranking step after the scoring.

Why are the conversion heads trained only on clicked posts?

Because «there was no conversion» on a post without a click does not mean a refusal — the user had no opportunity to perform the action. Training on such examples teaches the model that a lack of opportunity equals a negative answer, and produces systematic under-estimation.

This is the classic sample selection bias, the same one ESMM was invented for. Here the path of masking the loss is chosen: the head gets a gradient only on the subsample with a click, while the shared body of the model sees all the examples, so the representations are learned on the full data.

Why different optimisers for dense layers and embeddings?

The frequency with which they receive a gradient differs by orders of magnitude. A dense matrix is updated on every example of the batch; a particular row of the embedding table only when its item lands in the batch.

So the embeddings need a large step and adaptation per row — row-wise Adagrad at a rate of 0.28, while the dense part is trained with Muon at 7.1e-4, almost four hundred times smaller. A single optimiser would either strangle the embeddings with a small step or wreck the dense part with a large one.

A separate subtlety is the half-life of Adagrad's statistics of 2500 steps: without forgetting, the denominator grows monotonically, the effective step goes to zero and the model stops adapting to changed behaviour.

What is predicted by regression rather than classification, and why?

Dwell time, time after a click, active seconds — eight continuous quantities. They cannot be forced into a binary formulation without losing the main thing: the difference between «watched for three seconds» and «watched for thirty». Any threshold erases that difference.

The price is a heavy-tailed distribution and the need to reconcile the scale with the probabilities in the common sum. The first is cured by metrics robust to outliers, the second by a small weight on the continuous term in the score formula.

Why add noise to the «hour of the day» feature?

Because an hour is an artificial discretisation of continuous time. The difference between 13:59 and 14:01 is nil for behaviour, but for the feature it is a transition between values, and the model learns sharp boundaries at round hours — an artefact of the encoding, not a property of the users.

A ten-percent jitter blurs the boundaries and forces a smooth dependence to be learned. The same motive as in piecewise-linear encoding of continuous features, only the trick is cruder and more universal: it does not require changing the architecture.

The ranker is twice as wide as the retriever. Why not the other way round?

Because of the number of objects each has to process. The retriever produces embeddings for 28 million posts; making its tower more expensive is multiplied by that corpus. The ranker works with hundreds of candidates per request, and the cost of extra width is incomparably smaller.

This is the general principle of multi-stage systems we discussed in the chapter «The multi-stage funnel»: the further down the funnel, the fewer objects and the more expensive the model that is affordable. The selection stage is obliged to be simple, the ranking stage can afford complexity.

The history in the model is bidirectional, with no causal mask. Is that not a leak?

No. A leak would be if the model used information from the future relative to the moment of the prediction. Here the whole history is the past with respect to the candidates being scored; inside it, an event from the middle looking at a later one is entirely legitimate.

A causal mask is needed by autoregressive models predicting the next element of a sequence. Here the task is different: encode the history as a whole, and the prediction is taken from the candidate positions, which stand after all the history.

One-screen cheat sheet

The layout

2 profile tokens + 1022 history events + 64 candidates in one sequence.

The mask

A key from the history OR the key is the query itself. Candidates do not see each other.

What it gives

Cacheability, reproducibility, explainability, no quadratic term.

What it costs

The model does not see the slate. Diversity is brought in by a separate step after the scoring.

The heads

Discrete — the probabilities of actions. Continuous — dwell time and active seconds.

Loss masks

Conversions only on clicked posts. Negative feedback only on real impressions.

Optimisers

Muon for the dense layers (7.1e-4), row-wise Adagrad for the embeddings (0.28) with forgetting.

Sizes

2560 against the retriever's 1024; 20 query heads to 4 key heads.

Features

Profile, the moment, the age of the post, counters, a mutual follow. The hour of the day — with dithering.

Primary sources