Supplement · The X algorithm · page 6 of 11
Scoring: from action probabilities to one number
The most discussed part of the system and the most misunderstood one. Here we look at what exactly the model predicts, with what weights that is combined into one score, what happens to the score afterwards — and why the ratio of the weights does not support the conclusions usually drawn from it.
In brief
- The model predicts the probability of every action separately: a like, a reply, a repost, a click, a completed view, a follow, a report, a block. Not «relevance».
- The score is \(\sum_i w_i \cdot P(\text{action}_i)\). The weights lie in the code as ordinary numbers; positive actions with a plus, negative ones with a minus.
- The ratio of the weights of a report and a like is 468, and from that it does not follow that a report cancels out 468 likes: the weight is multiplied by a probability, and the probability of a report is several orders of magnitude smaller.
- On top of the sum come three corrections: decay on a repeated author, a discount for you not following, and lifting one post by a little-known author.
- Last of all works a separate re-ranking service, which selects posts by a determinantal point process over embeddings — that same DPP.
1. What the model predicts
The heads of the model are grouped by meaning. Here is the full list from the README, with an explanation of what each one means.
| Group | Actions | What it catches |
|---|---|---|
| Engagement | a like, a reply, a repost, a quote, a share, sending in a DM, copying the link | Explicit actions that require effort. The greater the effort, the stronger the signal |
| Clicks | on the post, on the profile, on the link, expanding a photo, opening a video, a click on the quoted post | Interest that did not reach an explicit action |
| Attention | a quality view of a video, dwelling, dwell time, time after a click, active seconds | How much attention the post actually received |
| The author | following the author | The strongest positive signal: the post turned out so good that the viewer wanted more |
| Negative | «not interested», hide the author, block, report, not dwelling | Explicit and implicit rejection |
Note the last row: «did not dwell» is a separately predicted quantity with a negative weight. Formally it is not an action of the user but its absence. We discussed such a move in the chapter «The problems of implicit data», when talking about implicit feedback: scrolling past is information too, and not using it is wasteful.
The temptation is clear: train the model to predict «the usefulness of the post» straight away and not suffer with weights. In practice that is not done, for two reasons, and both are visible in this code.
The first — the product policy changes more often than the model. The decision «replies are worth more than likes» is a decision about what feed we want, not about what the model can predict. Keeping it in the weights means changing a number in a config and rolling it out by experiment. Keeping it in the loss means retraining for every such decision.
The second — rare signals do not survive otherwise. A report happens several orders of magnitude less often than a like. In a single loss its contribution to the gradient is vanishingly small, and the model simply will not learn to predict it. A separate head learns it as a task of its own, and how strongly that influences the output is decided by the weight.
Exactly the same logic as in multi-task learning, only taken to the end: here all the target quantities are separated into heads, and the combination is moved outside the model.
2. The weights: the real numbers
The weights are declared by the param! macro — a name, a type, a key in the configuration system, a default value. The defaults in the repository are synchronised with the production ones by a script, so these are not invented numbers.
param!(FavoriteWeight, f64, "rust_home_mixer_favorite_weight", 0.5);
param!(ReplyWeight, f64, "rust_home_mixer_reply_weight", 5.0);
param!(ShareViaCopyLinkWeight, f64, "rust_home_mixer_share_via_copy_link_weight", 20.0);
param!(ReportWeight, f64, "rust_home_mixer_report_weight", -234.0);
A fragment of param.rs · code by X, Apache 2.0, commit 28e414f
Fragments of home-mixer/params/param.rs.
| Action | Weight | How to read it |
|---|---|---|
| Copying the link | +20.0 | The most expensive positive signal. A person carried the post outside the platform — so it is valuable enough to be shared by hand |
| A reply | +5.0 | Plus another +15.0 if the viewer and the author follow each other — a conversation between acquaintances is valued separately |
| A quote | +5.0 | A repost with your own text: more effort than a plain repost |
| Sending in a DM | +5.0 | Private distribution — a strong signal that is invisible in the public counters |
| Following the author | +4.0 | The post worked so well that the viewer wanted more |
| A share | +2.0 | |
| A repost | +1.0 | A cheap action, hence the moderate weight |
| A like | +0.5 | The most frequent and the cheapest action — hence the small weight |
| A click on the post | +0.4 | |
| Opening the link | +0.2 | |
| Expanding a photo · opening a video · a quality view | +0.05 | Weak signals of attention |
| The post has not been explored | +0.02 | A small bonus for posts there is little data about — see below |
| A click on the profile · dwelling | 0.0 | The head is trained but does not enter the score at present. A zero is a setting too |
| Did not dwell | −0.02 | A weak but very frequent signal |
| Block the author | −31.2 | |
| «Not interested» | −43.2 | |
| Hide the author | −58.8 | More expensive than a block: people block often in a conflict, but hide precisely because of the content |
| Report | −234.0 | The most expensive signal in the system |
Adding the weights up by group gives numbers that are used directly in the code:
- the sum of the positive weights — 43.32;
- the sum of the absolute values of the negative ones — 367.22;
- their sum — 410.54.
Note the asymmetry: the negative side weighs almost nine times more than the positive. That is a deliberate decision — the cost of showing something unpleasant is far higher than the benefit of showing something pleasant.
3. The formula and what happens to a negative score
The arithmetic in ranking_scorer.rs is straightforward: every term is a weight times a predicted probability, the positive and negative ones are accumulated separately only for reporting, and then the difference is taken.
let mut pos = 0.0;
let mut neg = 0.0;
for t in terms {
if t >= 0.0 { pos += t; } else { neg -= t; }
}
(pos, neg)
A fragment of ranking_scorer.rs · code by X, Apache 2.0, commit 28e414f
That is, the raw score is simply
$$ S_{\text{raw}} \;=\; \sum_i w_i \cdot P(\text{action}_i), $$where the weights of the negative actions are already negative. But this number is not used directly: it is passed through offset_score.
pub(crate) fn offset_score(combined_score: f64, w: &ScoringWeights) -> f64 {
if w.total_sum == 0.0 {
combined_score.max(0.0)
} else if combined_score < 0.0 {
(combined_score + w.negative_sum) / w.total_sum * NEGATIVE_SCORES_OFFSET
} else {
combined_score + NEGATIVE_SCORES_OFFSET
}
}
A fragment of ranking_scorer.rs · code by X, Apache 2.0, commit 28e414f
Let us work out what this does
The constant NEGATIVE_SCORES_OFFSET equals 0.001. Let us take both branches.
The score is non-negative. 0.001 is simply added to it. So any such post gets a result of no less than 0.001.
The score is negative. It is mapped linearly by the formula
$$ S_{\text{final}} \;=\; \frac{S_{\text{raw}} + 367.22}{410.54} \cdot 0.001. $$Look at the boundaries. The worst possible post is one where all the negative actions have probability 1 and the positive ones 0. Its raw score equals \(-367.22\), and the formula gives exactly 0. A score that just barely fell short of zero gives almost \(\frac{367.22}{410.54} \cdot 0.001 \approx 0.000894\).
It solves three tasks at once.
- The score is always non-negative. That matters because further down the conveyor it is multiplied by correction factors smaller than one. Multiplying a negative number by 0.25 means raising it — that is, a penalty would turn into a reward. Driving everything into the positive range closes that trap at the level of the arithmetic.
- The order is preserved completely. Negative scores lie in \([0;\,0.000894]\), positive ones start from \(0.001\) and up. No «bad» post can overtake any «good» one, and inside each group the relative order does not change: both branches are monotone.
- Bad posts are squeezed into a narrow band. The difference between «slightly bad» and «catastrophically bad» is thousandths. That is deliberate: once a post has gone negative, the details no longer matter, it will end up at the bottom anyway.
Note how much this resembles what we discussed in ranking metrics: what matters is the order, not the absolute values. Here the absolute values are deliberately deformed, as long as the order stays the same and the arithmetic downstream does not break.
4. The main misconception: «a report cancels out 468 likes»
The weight of a report is \(-234\), the weight of a like is \(0.5\). The ratio is 468. The number flew round the internet with the conclusion: one report destroys the effect of 468 likes.
The conclusion is wrong, and the developers deliberately added a comment about it to the code. The reason is simple: the weight is multiplied not by a count of events but by a predicted probability.
// Each weight multiplies the *predicted* probability of that
// ... the weights do not multiply raw engagement counts.
// One common misinterpretation is that you can read these weight
// ...
fn apply(score: Option<f64>, weight: f64) -> f64 {
score.unwrap_or(0.0) * weight
}
A fragment of ranking_scorer.rs · code by X, Apache 2.0, commit 28e414f
Let us take it in numbers. Suppose that for an ordinary post the model reckons you will like it with probability 0.02 and report it with probability 0.00005. Then the contributions are:
- the like: \(0.5 \times 0.02 = +0.010\);
- the report: \(-234 \times 0.00005 = -0.0117\).
The report outweighs the like by a factor of 1.17, not 468. The ratio of 468 is reached only in one case: if the model considers a report as likely as a like. But if the model thinks so, this is no longer an ordinary post, and its place at the bottom of the feed is exactly what was intended.
- The presets at the top set plausible collections of probabilities. On an ordinary post the contribution of a report is comparable with the contribution of a like — a ratio of about 1.2, not 468.
- Take the report slider and pull its probability up towards the probability of a like. The ratio moves towards 468, and the raw sum falls deep into the negative. That is exactly the condition under which the common interpretation becomes true.
- The «toxic» preset: the raw sum is negative, and the final score collapses to 0.0009 — that same narrow band under 0.001 into which all negative posts are squeezed.
- The «boring» preset is more interesting than the toxic one: there are no reports at all, but «did not dwell» with a probability of 0.72 at a weight of only −0.02, plus weak positives, give a negative sum. It is not only explicit negativity that cleans the feed.
What to say in an interview: «The weights are multiplied by predicted probabilities, not by counters. The ratio of the weights only says how many times more important an action is at equal probability; the actual contribution is determined by the product».
The same fact answers the question «can someone else's post be buried by mass reports». Directly — no, and here is why.
- What enters the score is your predicted probability of reporting, not other people's reports. The actions of others influence it only in so far as the model considers you similar to them.
- The recommendations are personalised: if a group with a particular behaviour reports a post, the effect will show up first of all in what is served to those similar to that group.
- For an action to get into the ranking training data at all, it has to happen on a post shown in the feed. Arriving by a direct link and pressing a button is not the same thing.
This, incidentally, is a good example of what was said in the chapter «A summary of biases»: a system that learns from its own output is resistant to manipulation from outside exactly to the extent that it ignores events outside its own output.
5. Three corrections on top of the score
The raw sum is not yet the final order. Three multipliers come next, and the order of their application in the code is: first the boost for a little-known author, then the decay on a repeated author, then the out-of-network discount.
Decay on a repeated author
The task is obvious: do not let one author take over the whole feed. The solution in the code is a multiplier depending on how many posts by this author already stand higher by score.
fn diversity_multiplier(decay_factor: f64, floor: f64, exponent: f64) -> f64 {
(1.0 - floor) * decay_factor.powf(exponent) + floor
}
A fragment of ranking_scorer.rs · code by X, Apache 2.0, commit 28e414f
Substituting:
| Which post by the author | \(k\) | Multiplier |
|---|---|---|
| the first | 0 | 1.000 |
| the second | 1 | 0.625 |
| the third | 2 | 0.438 |
| the fourth | 3 | 0.344 |
| the tenth | 9 | 0.251 |
| the limit | →∞ | 0.250 |
The shape of the formula is worth taking apart. It is not simply \(\text{decay}^k\) — otherwise the multiplier would go to zero and a sufficiently active author would disappear from the feed entirely. The construction \((1-\text{floor}) \cdot \text{decay}^k + \text{floor}\) is geometric decay raised onto a floor: it falls fast on the first repeats and comes to rest at 0.25.
The alternative suggests itself: simply do not let more than \(N\) posts by one author through. People do that, and it is called a quota. But the soft multiplier has an advantage that is visible precisely in this code.
A quota is a decision taken in advance and identical for everyone. A multiplier is a price: an author's fifth post can still get through if it is so good that even with a coefficient of 0.25 it overtakes other people's. If a person follows three authors and one of them wrote something outstanding today, a hard quota would cut off the good one, while a multiplier lets it through.
We discussed the same argument about soft and hard constraints when talking about diversity: MMR penalises similarity rather than forbidding it.
The out-of-network discount
Posts from people the viewer does not follow are multiplied by 0.75. The logic: such a post has to be noticeably better than «one of your own» to take its place, because the risk of a miss is higher.
But more interesting than the multiplier itself is the condition under which it applies:
let oon_applies = |c: &PostCandidate| match c.in_network {
Some(false) => true,
Some(true) => {
deboost_in_network_replies_retweets
&& (c.in_reply_to_tweet_id.is_some() || c.retweeted_tweet_id.is_some())
}
None => false,
};
A fragment of ranking_scorer.rs · code by X, Apache 2.0, commit 28e414f
The discount is received not only by other people's posts but also by replies and reposts from those you do follow (the flag is on by default). That is a subtle and correct move: by following a person you agreed to read their posts, not everything they comment on and forward. Without this line the feed of your follows would quickly turn into a stream of other people's conversations.
There are two more cases:
- if the request goes by a particular topic, the multiplier is different — 0.5: in a topical feed follows mean less and the requirements on someone else's post are stricter;
- for new users with enough follows a separate, milder multiplier applies — an obvious correction for the cold start of a viewer: there are few follows yet, and without other people's posts there would simply be no feed.
Lifting a little-known author
Here the mechanics work quite differently from what people usually assume, and that is worth going through carefully.
It is not a multiplier and it is applied not to all the posts of newcomers. Out of all the candidates the suitable ones are selected: an author with a follower count below a threshold (1000), a post with an impression count below a threshold (1000), fresh enough (no older than a day) and standing not too low in the current order. Out of those exactly one is taken — the best by score — and its score is set equal to the one standing at position 15–16 of the sorted list.
fn cold_start_target(query: &ScoredPostsQuery, scores: &[f64]) -> Option<f64> {
let mut ranked = scores.to_vec();
ranked.sort_by(|a, b| b.total_cmp(a));
let hi = (query.params.get(ColdStartSlotMax) as usize).min(ranked.len());
let lo = (query.params.get(ColdStartSlotMin) as usize).min(hi);
if lo >= hi { return None; }
Some(ranked[rand::rng().random_range(lo..hi)])
}
A fragment of author_cold_start.rs · code by X, Apache 2.0, commit 28e414f
It reads transparently: the target is not the first position but the middle of the visible part of the feed. The point is not «make the newcomer a star» but «give one of their posts a chance to be seen», so that the system gets any signal about it at all.
The situation is classic: the post has few impressions, so the estimate of its quality is based on a small sample and is very imprecise. Showing it means spending a position but getting information. Not showing it means staying in ignorance forever and cementing the cold start.
And in the code that is acknowledged outright. Alongside lies an alternative way of choosing the candidate — Thompson sampling with a prior of \(\text{Beta}(0.75,\ 49.25)\), choosing not the best post by score but a random one in proportion to the probability of being the best. The parameters are kept in the config, but by default this path is off and a simple choice of the best is used.
The prior \(\text{Beta}(0.75,\ 49.25)\) reads as «we assume in advance that a typical post by a new author gets a response in about 1.5% of cases» — the sum of the parameters, 50, sets how strongly we hold to that belief until data accumulates. This is exactly the smoothed CTR from the trainer and Thompson sampling, met in live code.
- Switch the decay on the author off: the top-5 is instantly taken over by one author — the one whose posts the model rates highly. Switch it back on and three or four different authors appear in the top. This is the most noticeable of the three corrections.
- Drop the floor to zero: the multiplier for the fourth post falls from 0.344 to 0.125, and an active author practically disappears after the second post. You can see what the floor is for.
- Find the row marked «lifted to position 15–16». That is that single post by a little-known author. Look at which position it came from.
- A detail visible only in the code: the target of the boost is computed on the scores before the decay and the discount. Those corrections then push the others down, while the boosted post usually does not fall under them — it is by a unique author and often ends up higher than the position it was aimed at. The actual effect of the boost is stronger than the parameters suggest.
What to say in an interview: «Diversity by author is implemented as a soft multiplier with a floor rather than a quota: a very good post can get through even as the fifth by the same author. Support for new authors is the lifting of one post into the middle of the feed, that is, explicit exploration in order to obtain a signal».
6. Re-ranking: DPP over embeddings
After the score has been computed and the corrections applied, a separate service is called. It selects posts by a determinantal point process — that same DPP we discussed in the chapter «Diversity and re-ranking» alongside MMR.
Let us recall the idea. A kernel \(L\) is built in which the quality of an item stands on the diagonal and the similarity off it. The determinant of the submatrix of the chosen set is maximised, and a determinant is the squared volume of the parallelepiped on the set's vectors. The length of an edge is set by quality, the angle between edges by dissimilarity. Two nearly identical posts give nearly collinear edges, the volume collapses — and the duplicate is filtered out by itself, with no separate penalty term.
The parameters this is called with live in two places, and that is worth distinguishing:
| Parameter | The service's value | The value sent by the feed | What it sets |
|---|---|---|---|
dpp_theta | 0.5 | 0.65 | The balance between quality and dissimilarity |
dpp_max_selected_rank | 100 | 150 | How far down the list rearranging is allowed |
dpp_top_k | 50 | — | How many positions the process selects |
embedding_dim | 1024 | — | The dimension of the post embeddings |
The first column is the defaults of the service's own arguments, the second the parameters the feed sends. The caller wins, so in production 0.65 and 150 are what work. The discrepancy is not a mistake: the service is standalone and has its own defaults in case it is run separately.
The restriction max_selected_rank deserves attention. Re-ranking does not touch the whole list — only the first 150 positions. There is no point beyond that: the user will not get there, and computing determinants is expensive.
We already noted on the overview page that candidates in the ranking transformer deliberately do not see each other. Which means the model fundamentally cannot take into account that three posts in a row are about the same thing: it scores each one separately.
Diversity is therefore moved into a separate step after the scoring, and that is a reasonable trade. The model stays consistent and cacheable, while the listwise effects are handled where they are easier to control: by a separate service with two comprehensible knobs that can be turned by experiment without retraining anything.
Note also that diversity here is brought in twice and in different ways: the decay on the author is about not getting stuck on one person, the DPP over embeddings about not getting stuck on one topic. These are different things, and one does not replace the other.
Common mistakes and hidden rocks
- Reading the ratio of the weights as a ratio of influence. Influence is \(w \cdot p\). The ratio of the weights works only at equal probabilities.
- Thinking that a zero weight means «the head was thrown out». A click on a profile and dwelling have a weight of 0.0: the heads are trained, the values are predicted, but they do not enter the score at present. That is the position of a knob, not the absence of a knob — and tomorrow it may become non-zero.
- Forgetting about
offset_score. The final score is never negative, and that is not cosmetics: further on it is multiplied by factors smaller than one, and a negative number would turn a penalty into a reward. - Thinking that the newcomer boost is a multiplier. It is the lifting of one post to the score of position 15–16.
- Considering the out-of-network discount a punishment for «foreign» posts. It also applies to replies and reposts from the people you follow — that is, it is equally a defence of the follow feed against other people's conversations.
- Confusing the decay on the author with the DPP. The first fights uniformity by people, the second by topics. The system has both.
Interview questions
Why can one not say that a report cancels out 468 likes?
Because the weight is multiplied by the predicted probability of the action, not by the number of actions that happened. On an ordinary post the probability of a report is two or three orders of magnitude smaller than the probability of a like, so the actual contributions are comparable: roughly \(-0.012\) against \(+0.010\).
The ratio of 468 is reached exactly when the model considers a report as likely as a like. But such a post should go down — in that case the mechanism works as intended, not as an injustice.
Why is the final score driven into the non-negative range?
Because further on it is multiplied by corrections smaller than one: the decay on the author, the discount for being outside the follows. Multiplying a negative number by 0.25 increases it — a penalty would turn into a reward, and a repeated post by a bad author would rise.
The mapping is arranged so that the order is preserved completely: negative scores are squeezed into \([0;\,0.000894]\), positive ones start at 0.001. A good post cannot end up below a bad one.
How is diversity by author implemented and why is that better than a quota?
By a multiplier \(m(k) = (1-\text{floor})\cdot\text{decay}^{k} + \text{floor}\) with parameters 0.5 and 0.25, where \(k\) is the number of posts by the same author higher up by score. That gives 1.0, 0.625, 0.438, 0.344 and so on, with a floor of 0.25.
The advantage over a quota: it is a price, not a ban. A good enough fifth post by an author can still get through if it overtakes other people's even with a coefficient of 0.25. A hard quota would cut it off regardless of quality. The floor is needed so that an author does not disappear entirely — without it the multiplier would go to zero.
Why do replies and reposts from people you follow get a discount?
Because a follow is an agreement to read a person's posts, not everything they comment on and forward. Without such a discount the follow feed would fill up with other people's conversations that got there through a single acquaintance.
Formally it is the same multiplier of 0.75 as for posts outside the follows; in the code the condition is combined. It is controlled by a separate flag, which is on by default.
What happens to the posts of new authors, and what do bandits have to do with it?
One post that passes the thresholds (few followers for the author, few impressions for the post, fresh) is lifted to the score of position 15–16. This is explicit exploration: we spend a position to get a signal about a post there is no data on.
Alongside in the code lies an alternative — choosing that post by Thompson sampling with a prior of \(\text{Beta}(0.75,\ 49.25)\) instead of «take the best by score». By default it is off, but the formulation itself is acknowledged as a bandit one outright. The prior corresponds to an expected response of about 1.5% with the strength of belief of 50 observations.
Why is a DPP needed if diversity is already brought in by the decay on the author?
These are different kinds of uniformity. The decay keeps one person from taking over the feed. The DPP over embeddings keeps one topic from taking it over — even if the posts are written by different authors.
Besides, the DPP solves a task inaccessible to the model itself: the candidates in the transformer do not see each other, so the model fundamentally does not know that three posts are about the same thing. Listwise effects are moved into a separate step, where they can be governed by two parameters without retraining.
How will you know that a weight has been chosen correctly?
You will not know analytically — the weights are picked by experiment. That is the main price of a multi-task scheme: every change of the weights requires an A/B test, and there are not enough tests for the whole grid of values.
In practice people look not at engagement in the moment but at long-term indicators — return rate, time to the next session, the share of negative reactions. This is exactly the conversation about proxy metrics and their divergence from the real goal that was in the chapter «Proxy metrics and long-term goals» and the chapter «True relevance and its proxies». The very fact that the weight of a report is so large, and the sum of the negative weights exceeds the sum of the positive ones ninefold, is precisely an attempt to defend against optimising momentary engagement.
One-screen cheat sheet
The formula
\(S = \sum_i w_i \cdot P(\text{action}_i)\), then offset_score, then three corrections.
The record-holding weights
Copying the link +20, a reply +5, a like +0.5. A report −234, hide the author −58.8.
The sums
Positive 43.32, negative 367.22, together 410.54. The negative side weighs nine times more.
offset_score
\(S \ge 0 \to S + 0.001\); \(S < 0 \to \frac{S + 367.22}{410.54}\cdot 0.001\). Everything non-negative, the order intact.
Decay on the author
\((1-0.25)\cdot 0.5^{k} + 0.25\): 1.0 → 0.625 → 0.438 → 0.344, a floor of 0.25.
The OON discount
×0.75 for other people's posts and for your follows' replies and reposts. In a topical feed ×0.5.
The newcomer boost
One post lifted to the score of position 15–16. Thresholds: 1000 followers, 1000 impressions, a day.
DPP
θ = 0.65, rearranges only the first 150 positions, embeddings of dimension 1024.
The order of application
The newcomer boost → the decay on the author → the OON discount → DPP.
Primary sources
- home-mixer/params/param.rs — all the weights and parameters with the authors' comments on how to read them.
- home-mixer/scorers/ranking_scorer.rs — the arithmetic of the score,
offset_score, the decay, the OON discount. - home-mixer/scorers/author_cold_start.rs — lifting a little-known author and Thompson sampling.
- vm-ranker/dpp.rs and args.rs — the re-ranking and its parameters.
- The course: the chapter «Multi-task learning» on multi-task learning, the chapter «Diversity and re-ranking» on MMR and DPP, the chapter «Thompson sampling» on Thompson sampling.