Разбираю рекомендательную ленту свитера (анализ алгоритма ранжирования)
X опубликовали репозиторий с исходным кодом своих рекомендательных алгоритмов в 2023 году. Там нет конкретных весов и многих переменных, но есть общие принципы. По репе можно понять общую механику того, как именно контент попадает в ленту.
Похожие рекомендательные системы используются и в других соцсетях.
Главная проблема — из сотен миллионов ежедневных твитов невозможно прогнать через тяжелую Ranker-нейросеть каждый. Поэтому используются быстрые алгоритмы, чтобы отобрать топ кандидатов (конкретное число скрыто в params.rs). Для out-of-network контента X использует SimClusters — алгоритм, который находит сообщества пользователей с похожими интересами. Если вы попали в кластер "Любители Rust", а пост популярен в этом кластере, он попадёт в кандидаты, даже если вы не подписаны на автора.
Потом идёт второй этап — Grok-трансформер, который для отобранных кандидатов предсказывает вероятность 19 различных действий (лайк, ретвит, ответ, шер в личку и другие).
Архитектура воронки
Система работает по принципу сужающейся воронки. Из сотен миллионов ежедневных постов невозможно прогнать каждый через сложную нейросеть. Поэтому процесс разделен на два этапа:
Этап 1: отбор кандидатов
Это стадия «грубого отсева». Задача — из сотен миллионов постов выбрать топ наиболее релевантных для конкретного юзера. Здесь работает модель «Двух башен» (Two-Tower Model):
-
User Tower: анализирует историю взаимодействий пользователя и кодирует его интересы в цифровой вектор.
-
Candidate Tower: кодирует сам пост (текст, медиа, автора) в такой же вектор.
Система просто сравнивает эти векторы. Если вектор поста математически далек от вектора интересов пользователя (например, пользователь интересуется футболом, а пост про вязание), пост отсеивается мгновенно. На этом этапе качество контента не оценивается глубоко — только релевантность теме.
Как это выглядит в коде (phoenix/recsys_retrieval_model.py)
User Tower — кодирует интересы пользователя:
def _get_action_embeddings(self, actions: jax.Array) -> jax.Array:
_, _, num_actions = actions.shape # [B, seq_len, num_actions]
actions_signed = (2 * actions - 1).astype(jnp.float32) # [0,1] → [-1,+1]
action_emb = jnp.dot(actions_signed, action_projection)
return action_emb # [B, seq_len, emb_size]
Последние N действий (в demo N=32, default 128) → Transformer → mean pooling → L2 normalize.
Candidate Tower — кодирует пост:
hidden = jnp.dot(post_author_embedding, proj_1) # → 256 dims
hidden = jax.nn.silu(hidden)
candidate_embeddings = jnp.dot(hidden, proj_2) # → 128 dims
candidate_representation = candidate_embeddings / jnp.linalg.norm(candidate_embeddings)
Retrieval = dot product:
scores = jnp.matmul(user_representation, corpus_embeddings.T)
top_k_scores, top_k_indices = jax.lax.top_k(scores, k)
Оба вектора L2-normalized, поэтому dot product = cosine similarity. От -1 до +1.
Этап 2 — ранжирование
Оставшиеся кандидаты поступают в Grok-трансформер. На этом этапе для каждого поста рассчитывается вероятность того, что пользователь совершит с ним одно из целевых действий.
Алгоритм жёстко разделяет источники контента на два потока:
-
In-Network: посты авторов, на которых пользователь подписан.
-
Out-of-Network (OON): посты от незнакомцев.
По умолчанию система пессимизирует контент от незнакомых авторов. К итоговой оценке таких постов применяется понижающий коэффициент (OON_WEIGHT_FACTOR). Это значит, что пост от незнакомца должен быть значительно интереснее поста от друга, чтобы занять то же место в ленте.
Как это выглядит в коде (oon_scorer.rs)
match c.in_network {
Some(false) => base_score * OON_WEIGHT_FACTOR,
_ => base_score,
}
In-network посты получают base_score без изменений. Out-of-network умножаются на штрафной коэффициент. Конкретное значение OON_WEIGHT_FACTOR скрыто в params.rs.
Технически: in-network контент приходит через Thunder (in-memory хранилище, sub-millisecond latency), out-of-network — через Phoenix (ML-модель retrieval).
Чтобы показать пользователю контент от незнакомца, алгоритм использует механизм SimClusters (описан в документации X, сам код не в открытом репозитории). Это технология матричной факторизации, которая разбивает пользователей на сообщества по интересам.
-
Пример: вы лайкаете посты про язык Rust. Система относит вас к кластеру «Rust-разработчики».
-
Механика: если пост набирает высокую вовлеченность (лайки/шеры) внутри этого кластера, он получает «зеленый свет» для показа всем участникам кластера, даже если они не подписаны на автора.
SimClusters — это и есть механизм виральности. Пост сначала должен победить внутри узкой тематической ниши, чтобы алгоритм решился показать его широкой аудитории.
«Затухание» охватов
В модуле author_diversity_scorer.rs реализован механизм защиты от доминирования одного автора. Если пользователь публикует посты очередями, система искусственно занижает их охват.
Используется формула с коэффициентом затухания (decay factor). Для иллюстрации — если decay_factor = 0.7:
-
1-й пост в ленте подписчика получает множитель 1.0 (100% возможного скора).
-
2-й пост подряд получает множитель около 0.7 (70%).
-
3-й пост — около 0.49 (49%).
Важно: это работает per-request (за один запрос ленты), а не накопительно за день. Штрафуется не частота постинга, а количество твоих постов в одном ответе ленты.
Формула из author_diversity_scorer.rs
fn multiplier(&self, position: usize) -> f64 {
(1.0 - self.floor) * self.decay_factor.powf(position as f64) + self.floor
}
Где position — какой по счёту твой пост в ленте этого юзера. Параметр floor задаёт минимальный множитель (чтобы скор не упал до нуля). HashMap с позициями авторов обнуляется каждый запрос.
Ранжирование
Нейросеть оценивает вероятность 19 различных действий.
-
Share > Like. Лайк — это «дешевое» действие с одним весовым параметром. Репост и отправка в личные сообщения (DM) суммируют сразу три весовых бонуса. Контент, которым хочется поделиться, ценится системой выше, чем контент, который просто нравится.
SHARE_WEIGHT, SHARE_VIA_DM_WEIGHT, SHARE_VIA_COPY_LINK_WEIGHT — когда пост отправляют в личку, эти веса суммируются. «Контент настолько хорош, что им делятся лично».
Учитывается не только клик, но и время, проведенное на посте.
Бинарный показатель: остановился ли пользователь на посте? (Да/Нет).
Непрерывный: сколько миллисекунд он на нем провел.
Длинные тексты и видео получают преимущество именно за счет второго параметра. Кликбейтный заголовок может дать клик, но если пользователь сразу ушел, низкий dwell_time обвалит рейтинг поста.
Все 19 действий и код weighted_scorer.rs
14 позитивных: favorite, reply, repost, quote, click, profile_click, video_quality_view, photo_expand, share, share_via_dm, share_via_copy_link, dwell, follow_author, quoted_click
4 негативных: not_interested, block_author, mute_author, report
1 непрерывный: dwell_time (время залипания)
Важно: repost (ретвит) — это отдельный сигнал с одним весом RETWEET_WEIGHT. Три веса относятся именно к share (кнопка «поделиться»), не к ретвиту.
fn compute_weighted_score(candidate: &PostCandidate) -> f64 {
let combined_score =
Self::apply(s.favorite_score, p::FAVORITE_WEIGHT)
+ Self::apply(s.share_score, p::SHARE_WEIGHT)
+ Self::apply(s.share_via_dm_score, p::SHARE_VIA_DM_WEIGHT)
+ Self::apply(s.share_via_copy_link_score, p::SHARE_VIA_COPY_LINK_WEIGHT)
+ // ... 15 других действий
;
Self::offset_score(combined_score)
}
fn apply(score: Option<f64>, weight: f64) -> f64 {
score.unwrap_or(0.0) * weight
}
Скрытые факторы аккаунта
Помимо текста и картинки, на ранжирование влияют параметры самого аккаунта.
-
Tweepcred (описан в документации, код не в открытом репозитории) — скрытый рейтинг качества, рассчитываемый по принципу PageRank. Зависит от качества взаимодействий. Если автор часто взаимодействует с ботами, спамит или получает жалобы, его Tweepcred падает. При низком рейтинге посты могут отсеиваться ещё до стадии Retrieval.
-
Twitter Blue — в открытом коде нет явного множителя для платных аккаунтов. Если буст и существует, он скрыт в params.rs или работает на уровне retrieval.
До финального начисления баллов пост проходит через каскад из 12 фильтров.
-
Социальный граф: если автор находится в блоке или мьюте у пользователя, пост удаляется немедленно.
-
Слова-триггеры: фильтр MutedKeywordFilter удаляет посты, содержащие слова, которые пользователь скрыл в настройках.
-
Негатив: сигналы «Не интересно» или жалобы работают как мощные пессимизаторы, способные обнулить охват не только конкретного поста, но и повлиять на репутацию автора в будущем.
Полный список 12 фильтров с примерами кода
До скоринга (10 штук):
-
AgeFilter — пост старше MAX_POST_AGE? Удалить. Timestamp встроен в Snowflake ID:
fn is_within_age(&self, tweet_id: i64) -> bool {
snowflake::duration_since_creation_opt(tweet_id)
.map(|age| age <= self.max_age)
.unwrap_or(false)
}
-
MutedKeywordFilter — token-level matching, не просто substring.
-
PreviouslySeenPostsFilter — использует Bloom Filter (exact + probabilistic matching).
-
AuthorSocialgraphFilter — блок/мьют = жёсткое удаление, не снижение скора.
-
DropDuplicatesFilter — дубликат по tweet_id.
-
CoreDataHydrationFilter — нет author_id или пустой текст.
-
IneligibleSubscriptionFilter — платный контент без подписки.
-
PreviouslyServedPostsFilter — уже показывали в этой сессии (pagination).
-
RetweetDeduplicationFilter — повторный ретвит:
if seen_tweet_ids.insert(rt_id) { kept.push(candidate); }
else { removed.push(candidate); }
-
SelfTweetFilter — твой собственный пост.
После скоринга (2 штуки):
-
VFFilter — пост помечен как спам/насилие/нарушение.
-
DedupConversationFilter — в одной ветке разговора оставить только лучший по скору.
Негативные сигналы
Когда кто-то нажимает «Не интересно», block, mute или report — это негативные сигналы. Они работают как пессимизаторы: обнуляют охват поста и влияют на скор автора в будущем.
Block и mute работают на двух уровнях. Сначала block_author_score и mute_author_score снижают скор в weighted sum. Потом AuthorSocialgraphFilter полностью удаляет пост. Двойная защита.
Про shadow ban и offset formula
Shadow ban? В открытом коде нет явного флага is_shadowbanned. Есть negative scoring, который работает предсказуемо — чем больше негативных сигналов, тем ниже скор.
Offset formula: если combined_score становится отрицательным, срабатывает нормализация:
if combined_score < 0.0 {
(combined_score + NEGATIVE_WEIGHTS_SUM) / WEIGHTS_SUM * NEGATIVE_SCORES_OFFSET
}
Это не даёт отрицательным скорам «взорваться» в минус бесконечность.
Итого
Алгоритм X — это не магия. Это математика: Two-tower retrieval (Phoenix) решает, существуешь ли ты для out-of-network аудитории. In-network идёт через Thunder напрямую. Grok-трансформер предсказывает 19 типов engagement. Weighted scorer складывает всё в один скор. 12 фильтров убивают плохой контент.
Чтобы попасть в retrieval:
-
Контент ближе к истории engagement пользователя имеет больше шансов
-
In-network подписчики получают контент без retrieval-барьера
Чтобы пройти фильтры:
-
Не попадай под блоки и мьюты — это жёсткое удаление, не снижение скора
-
Свежий контент лучше старого (AgeFilter)
Чтобы максимизировать скор:
-
Создавай контент, который провоцирует на «дорогие» действия (шеры, чтение), а не на «дешёвые» (лайки). Share даёт три сигнала, like — один.
-
Dwell time — два сигнала (бинарный + непрерывный). Длинный контент, на котором залипают, выигрывает.
-
Расти подписчиков — они видят без OON-штрафа.
Что скрыто в params.rs
Весь код открыт, кроме params.rs и модулей clients/, util/.
Там спрятаны:
-
MAX_POST_AGE — сколько живёт пост
-
MIN_VIDEO_DURATION_MS — минимальная длина видео для бонуса
-
Все веса: FAVORITE_WEIGHT, REPLY_WEIGHT, RETWEET_WEIGHT...
-
OON_WEIGHT_FACTOR — штраф за out-of-network
-
decay_factor и floor для Author Diversity
Структура открыта. Конкретные числа — нет.