Новости ChatGPT

Разбираю рекомендательную ленту свитера (анализ алгоритма ранжирования)

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-трансформер. На этом этапе для каждого поста рассчитывается вероятность того, что пользователь совершит с ним одно из целевых действий.

Алгоритм жёстко разделяет источники контента на два потока:

  1. In-Network: посты авторов, на которых пользователь подписан.

  2. 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 штук):

  1. 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)
}
Объяснить код с
  1. MutedKeywordFilter — token-level matching, не просто substring.

  2. PreviouslySeenPostsFilter — использует Bloom Filter (exact + probabilistic matching).

  3. AuthorSocialgraphFilter — блок/мьют = жёсткое удаление, не снижение скора.

  4. DropDuplicatesFilter — дубликат по tweet_id.

  5. CoreDataHydrationFilter — нет author_id или пустой текст.

  6. IneligibleSubscriptionFilter — платный контент без подписки.

  7. PreviouslyServedPostsFilter — уже показывали в этой сессии (pagination).

  8. RetweetDeduplicationFilter — повторный ретвит:

if seen_tweet_ids.insert(rt_id) { kept.push(candidate); }
else { removed.push(candidate); }
Объяснить код с
  1. SelfTweetFilter — твой собственный пост.

После скоринга (2 штуки):

  1. VFFilter — пост помечен как спам/насилие/нарушение.

  2. 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

Структура открыта. Конкретные числа — нет.