Дополнение · X-алгоритм · страница 5 из 11
Ранжирование: трансформер, который запрещает кандидатам смотреть друг на друга
Retrieval отобрал сотни постов — теперь их надо упорядочить. Разбираем ранжирующую модель Phoenix: как история и кандидаты укладываются в одну последовательность, что именно делает маска внимания, почему предсказаний много и как их обучают, если данные для разных голов приходят из разных мест.
Коротко
- Одна последовательность на всё: два токена профиля, до 1022 событий истории и 64 кандидата подряд. Не «модель на пару пользователь-пост», а один прогон на весь слейт.
- Маска изоляции — одна строка в ядре внимания. Запрос смотрит на ключ, только если ключ из истории или ключ — это сам запрос. Кандидаты не видят друг друга физически.
- Ранкер крупнее ретривера: размерность 2560 против 1024, 20 голов запросов против 16. Кандидатов уже сотни, а не 28 миллионов, — можно позволить себе модель дороже.
- Головы обучаются на разных подвыборках. Конверсионные — только на кликнутых постах, головы негативной обратной связи — только на реальных показах. Это та самая борьба с сдвигом выборки, что и в ESMM.
- Кроме вероятностей, модель предсказывает непрерывные величины — время задержки и активные секунды. Это регрессия, а не классификация, и в скор она входит своим слагаемым.
1. Как всё укладывается в одну последовательность
Первое, что стоит уложить в голове: это не модель вида «подай пару (пользователь, пост) — получи число». За один прогон обрабатывается весь набор кандидатов сразу, и укладывается он так:
Такая укладка — прямое продолжение того, что мы разбирали в главе «Трансформеры над историей»: история пользователя как последовательность, кандидат как ещё один токен, внимание вместо ручных агрегатов. Разница в том, что здесь кандидатов сразу 64, и именно это порождает главный вопрос страницы.
Параметр right_anchored_rope: True означает, что позиционные коды считаются не от начала истории, а от её конца. Зачем: длина истории у людей разная — у кого-то 30 событий, у кого-то 1022. При обычной нумерации «от начала» последнее событие у первого окажется на позиции 30, у второго на 1022, и модели придётся отдельно учиться понимать «а какая позиция здесь означает недавнее».
С привязкой к правому краю последнее событие всегда имеет одну и ту же позицию относительно кандидатов. Модель учит одну зависимость «свежесть → важность» вместо тысячи вариантов.
2. Маска изоляции: одна строка, определяющая всю систему
На странице обзора мы упомянули, что кандидаты не видят друг друга. Теперь посмотрим, как это записано. Каждому токену присваивается сегмент:
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)
А внутри ядра внимания стоит вот это:
mask = jnp.logical_or(seg_k == HISTORY_SEGMENT_ID, span_q[:, None] == span_k[None, :])
Фрагмент из ranker_attention_v2.py · код X, Apache 2.0, коммит 28e414f
Прочитаем медленно. Запрос на позиции \(q\) может смотреть на ключ на позиции \(k\) тогда и только тогда, когда выполнено хотя бы одно из двух:
- ключ принадлежит истории или профилю —
seg_k == 1; - ключ — это сам запрос —
span_q == span_k.
Всё. Никаких других вариантов. Значит:
| Запрос | Видит | Не видит |
|---|---|---|
| токен истории | всю историю и профиль | кандидатов |
| кандидат | всю историю, профиль, себя | другие кандидаты |
Обратите внимание: история — двусторонняя. Никакой причинной маски по умолчанию нет, событие из середины истории спокойно смотрит на более позднее. Это не языковая модель, предсказывающая следующий токен; это энкодер, и запрещать ему смотреть вперёд по истории нет причин.
Формально маску можно было бы просто построить как матрицу и умножить. Но она разреженная и структурированная, поэтому её вшили в само ядро внимания. В коде для каждого блока запросов выполняются два цикла:
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))
Фрагмент из ranker_attention_v2.py · код X, Apache 2.0, коммит 28e414f
Первый цикл проходит по всем блокам истории. Второй — только по блокам, которые пересекаются с самим блоком запроса, то есть по диагонали. Блоки «кандидат × чужой кандидат» вообще не загружаются в память.
Это важно не только для чистоты: внимание квадратично по длине, и 64 кандидата, смотрящие друг на друга, — это \(64^2\) лишних произведений на каждый слой и каждую голову. Изоляция здесь экономит вычисления, а не только обеспечивает консистентность.
- Матрица нарисована ровно по строке из ядра: синие клетки — ключ из истории, жёлтые — «сам себе». Строки кандидатов пустые везде, кроме истории и диагонали.
- Нажмите «подменить соседей по батчу» при включённой изоляции: скор кандидата 1 не меняется ни в одном знаке. Расхождение ровно ноль — не «мало», а ноль по построению.
- Теперь выключите изоляцию. Появляются розовые клетки — связи между кандидатами. Подмените соседей ещё раз: скор поехал. В нашем примере с 0.012 на −0.269.
- Посчитайте разрешённые клетки: изоляция убирает их заметную долю, и это прямая экономия вычислений.
Что сказать на собесе: «Изоляция кандидатов делает скор функцией только от пары (пользователь, пост). Отсюда кэшируемость, воспроизводимость и объяснимость — ценой того, что модель не видит слейт и не может сама навести разнообразие».
Чем за это платят
Размен стоит проговорить честно, потому что на собеседовании обычно спрашивают именно про обратную сторону.
Модель, которая видит слейт целиком, принципиально сильнее. Она может заметить, что три поста подряд про одно и то же, что второй пост дублирует первый, что после длинного видео стоит поставить короткий текст. Это списочная постановка, и она даёт лучшее качество при прочих равных.
Отказавшись от неё, система получает четыре вещи:
- Кэшируемость. Скор пары (пользователь, пост) можно сохранить и переиспользовать при следующем запросе. В коде ленты есть отдельный источник закэшированных отранжированных постов — он существует именно благодаря этому свойству.
- Воспроизводимость. Два одинаковых запроса дают одинаковый порядок. Без изоляции порядок зависел бы от того, как посты разбились по батчам.
- Объяснимость. На вопрос «почему этот пост оказался ниже» есть ответ, не содержащий слов «потому что рядом оказался другой пост».
- Скорость. Квадратичный член по кандидатам исчезает.
А списочные эффекты возвращают позже и отдельно — переранжированием через детерминантный процесс, которое мы разбирали на странице скоринга. Получается разделение труда: модель отвечает за «насколько хорош этот пост для этого человека», отдельный сервис — за «как эти посты выглядят вместе».
3. Головы: что именно предсказывается
С каждой позиции кандидата снимается два набора чисел.
Дискретные головы — вероятности действий: лайк, ответ, репост, цитата, клик, разворот фото, открытие видео, подписка на автора, жалоба, блокировка, скрытие, «не интересно» и так далее. Полный список и веса, с которыми они сводятся, — на странице скоринга.
Непрерывные головы — величины, у которых нет «случилось или нет»: время задержки на посте, время после клика, активные секунды. Это регрессия. В конфиге их восемь.
Соблазн: объявить «задержался дольше N секунд» бинарным событием и предсказывать вероятность. Так делают, и это даже работает, но теряется главное — сколько именно. Разница между 3 и 30 секундами исчезает, если порог стоит на пяти.
Регрессия эту разницу сохраняет, но приносит свои беды: распределение времени тяжелохвостое, выбросы тянут среднее, а масштаб цели надо согласовывать с масштабом остальных слагаемых скора. В конфиге видно, что метрики по непрерывным целям считают через среднюю абсолютную ошибку (continuous_metrics_mae_mean) — устойчивая к выбросам мера вместо квадратичной.
В весах у времени задержки стоит коэффициент 0.004 — маленький ровно потому, что величина измеряется в секундах и без масштабирования затопила бы все вероятности.
4. Самое интересное: головы учатся на разных подвыборках
Тут начинается то, ради чего стоит читать чужой продовый код. Наивно кажется, что все головы обучаются на одних и тех же примерах. В действительности для каждой головы отдельно решается, считается ли этот пример информативным.
Конверсии — только на кликнутых
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)
Фрагмент из recsys_model.py · код X, Apache 2.0, коммит 28e414f
Читается так: если по посту не было клика, то лосс конверсионных голов на этом примере обнуляется. Они не получают градиента вовсе.
Логика железная. Что означает «конверсия не произошла» для поста, по которому не кликали? Ничего. Пользователь не отказался от действия — у него просто не было возможности его совершить. Обучать голову на таком примере значит учить её, что «отсутствие возможности» — это «отказ».
Это ровно та задача сдвига выборки, ради которой в главе «ESMM и сдвиг выборки» придумали ESMM: там показывалось, что модель конверсии, обученная только на кликах, при выкатке на все показы систематически ошибается. Решения два — либо моделировать всю цепочку целиком через произведение вероятностей, либо честно ограничить обучающую выборку. Здесь выбран второй путь, но с важным дополнением: обучение идёт совместно, в одной модели, поэтому общее тело всё равно видит все примеры, а специализируется только голова.
Негативная обратная связь — только на реальных показах
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)
Фрагмент из recsys_model.py · код X, Apache 2.0, коммит 28e414f
Здесь та же идея с другой стороны. В обучающую выборку добавляют засэмплированные негативы — посты, которые пользователю никогда не показывали. Для голов вроде «лайкнет ли» это нормально: не показали — значит, почти наверняка не лайкнул бы.
Но для головы «пожалуется ли» такой пример ядовит. Отсутствие жалобы на непоказанный пост — не свидетельство того, что жалобы бы не было. Обучив на этом, мы получим модель, уверенную, что жалуются редко, — и она будет права статистически и бесполезна практически.
Поэтому головы негативной обратной связи на засэмплированных негативах не обучаются вовсе. Разбирали ровно эту ловушку в главе «Проблемы implicit-данных»: отсутствие сигнала и отрицательный сигнал — разные вещи, и путать их дорого.
Перед всеми масками выполняется вот что:
zero_mask = has_negative_feedback_action_expanded & non_negative_feedback_action_mask
targets_for_loss = jnp.where(zero_mask, False, targets_for_loss)
Фрагмент из recsys_model.py · код X, Apache 2.0, коммит 28e414f
Если по посту была негативная реакция, то все положительные метки на нём обнуляются. Лайкнул и тут же пожаловался — для положительных голов это больше не позитив.
Тот же принцип мы видели в retrieval, где позитивом считался лайк при отсутствии любого негатива. Здесь он же применён на уровне меток. Практический смысл: не учить систему доставать кликбейт, который собирает реакцию и тут же вызывает отторжение.
5. Признаки: что модель знает о посте и о моменте
Блок FeaturePrepConfig в конфиге — это, по сути, список всего, что подаётся модели помимо истории и идентификаторов.
| Группа | Признаки | Зачем |
|---|---|---|
| Зритель | страна, язык, локация, пол, возраст, установленные приложения, IP | Грубая персонализация там, где истории мало |
| Момент | час дня, часовой пояс, поверхность продукта | Утром и вечером человек читает разное |
| Пост | возраст поста, счётчики вовлечения, semantic ID | Свежесть и накопленный отклик |
| Отношение | подписан ли зритель на автора, подписан ли автор на зрителя | Двусторонняя подписка — сильный сигнал знакомства |
| Поведение | время задержки | Используется и как признак истории, и как цель |
Две строчки заслуживают отдельного разбора.
hour_of_day_dither_fraction: 0.1
Фрагмент из xrecsys.py · код X, Apache 2.0, коммит 28e414f
Час дня подаётся с десятипроцентным случайным дрожанием. Зачем портить признак шумом?
Затем, что час — искусственно дискретная величина. Разница между 13:59 и 14:01 для человека нулевая, а для признака это переход между корзинами. Без дизеринга модель выучивает резкие границы на круглых часах — артефакт разметки, а не свойство поведения. Дрожание размывает границы и заставляет учить плавную зависимость.
Это тот же мотив, что у кусочно-линейного кодирования: там непрерывность достигалась конструкцией кодировки, здесь — шумом. Приём грубее, зато применим к любому признаку без изменения архитектуры.
post_age_granularity_mins: 60
Фрагмент из xrecsys.py · код X, Apache 2.0, коммит 28e414f
Возраст поста округляется до часа. Причина практическая: без округления возраст — непрерывно растущее число, и один и тот же пост в двух соседних запросах имеет разные признаки. Это ломает кэширование скора, о котором шла речь выше: закэшированное значение немедленно устаревает.
С округлением до часа скор остаётся валидным в пределах часа. Модель теряет разрешение, которое ей почти не нужно — разница между постом возрастом 3 часа 10 минут и 3 часа 50 минут пренебрежима, — и взамен получает кэш, который живёт.
6. Обучение
Здесь несколько решений, каждое из которых заслуживает пояснения.
Два оптимизатора для одной модели
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,
),
),
Фрагмент из xrecsys.py · код X, Apache 2.0, коммит 28e414f
Плотные слои учатся Muon, таблицы эмбеддингов — построчным Adagrad. Это не эклектика, а следствие того, что у них принципиально разная структура градиента.
- Плотная матрица получает градиент на каждом примере батча. Muon — сравнительно новый оптимизатор, работающий с матрицей весов как с матрицей, а не как с плоским вектором параметров: он выравнивает спектр обновления, не давая одному направлению доминировать. Скорость обучения 7.1e-4.
- Строка таблицы эмбеддингов получает градиент только когда её айтем попал в батч, — то есть в тысячи раз реже. Ей нужен большой шаг (0.28, в четыреста раз больше!) и индивидуальная адаптация: Adagrad накапливает историю градиентов по каждой строке отдельно.
Параметр half_life_steps: 2500 задаёт период полураспада накопленной статистики Adagrad: без забывания знаменатель растёт монотонно, шаг уходит в ноль, и старые строки перестают обучаться. С забыванием модель остаётся способной адаптироваться к изменившемуся поведению — прямой ответ на дрейф данных.
А lazy_decay: True означает, что забывание применяется к строке лениво, в момент её следующего обновления, а не ко всей таблице каждый шаг. Для таблицы на миллионы строк это разница между «дорого» и «бесплатно».
Упаковка последовательностей
Истории у людей разной длины, а тензор прямоугольный. Наивное решение — дополнять всех до максимума — означает, что при средней длине 510 и максимуме 1022 половина вычислений уходит на паддинг.
В конфиге включён use_seqpack с распределением длин Beta от 126 до 1022 со средним 510. Несколько коротких историй укладываются в один физический ряд, а разделяются они теми самыми сегментными идентификаторами, которые мы разбирали в маске. Внимание не даёт им перетечь друг в друга.
Красивая деталь: механизм сегментов, введённый ради изоляции кандидатов, бесплатно решил и задачу упаковки. Одно и то же поле служит двум целям.
LogQ-коррекция и здесь
В конфиге ранжирования стоит log_q_correction: True. Может показаться странным: коррекция обсуждалась в контексте retrieval, где негативы сэмплируются из батча. Но в обучении ранкера тоже есть засэмплированные негативы — посты, которые пользователю не показывали, добавленные, чтобы модель видела не только выданное.
Сэмплируются они не равномерно, и без поправки ранкер унаследует то же смещение к популярному. Механика та же, что подробно разобрана на странице retrieval и в виджете LogQ.
7. Конфигурация ранкера против ретривера
| Параметр | Retrieval | Ranking | Почему разница |
|---|---|---|---|
| размерность | 1024 | 2560 | Ранкер работает с сотнями кандидатов, а не с 28 миллионами — можно позволить дороже |
| слоёв | 8 | 8 | Глубина одинаковая, растёт ширина |
| голов запросов / ключей | 16 / 4 | 20 / 4 | Больше голов запросов при том же кеше ключей |
| история | 1023 | 1022 + 2 токена профиля | В сумме 1024 — кратно тайлам ядра |
| кандидатов | 64 | 64 | |
| нормировка запросов и ключей | выключена | включена | Ранкер глубже по эффективной ширине, стабильность важнее |
| ограничение логитов внимания | 80.0 | выключено | Роль стабилизатора взяла на себя нормировка |
| внимание между уровнями semantic ID | включено | выключено | У ранкера богатый контекст и без этого |
| скорость обучения | 2e-3 | 7.1e-4 | Модель больше — шаг меньше |
Строка про нормировку запросов и ключей стоит комментария. qk_norm нормирует векторы перед вычислением скалярного произведения, ограничивая величину логитов внимания сверху. Альтернатива — жёстко обрезать логиты, что и делает ретривер параметром 80.0. Оба приёма лечат одну болезнь: расходящееся внимание, когда один логит убегает вперёд, софтмакс схлопывается в дельта-функцию и градиент умирает. В ранкере выбрали более мягкий из двух.
Частые ошибки и подводные камни
- Думать, что изоляция кандидатов — это про экономию. Экономия есть, но главное — консистентность: скор становится функцией только от пары (пользователь, пост), и лишь поэтому его можно кэшировать и воспроизводить.
- Считать, что все головы учатся на одних примерах. Конверсионные — только на кликнутых постах, головы негативной обратной связи — только на реальных показах. Маска лосса решает это отдельно для каждой головы.
- Путать «нет сигнала» и «отрицательный сигнал». Отсутствие жалобы на непоказанный пост не означает, что жалобы бы не было. Именно поэтому такие примеры из обучения этих голов исключены.
- Считать историю причинно замаскированной. Она двусторонняя: это энкодер, а не языковая модель. Причинная маска здесь была бы лишним ограничением.
- Не замечать, что округление возраста поста — про кэш. Гранулярность в час нужна не для точности, а чтобы закэшированный скор не устаревал каждую минуту.
- Думать, что один оптимизатор на всю модель — норма. У плотных слоёв и таблиц эмбеддингов частота обновления различается на порядки, и один шаг обучения на всех либо задушит эмбеддинги, либо взорвёт плотную часть.
Вопросы с собеседований
Как реализована изоляция кандидатов и что она даёт?
Одним условием в ядре внимания: запрос видит ключ, только если ключ принадлежит истории либо ключ — это сам запрос. Кандидаты физически не могут смотреть друг на друга; блоки «кандидат × чужой кандидат» даже не загружаются в вычисление.
Даёт четыре вещи. Кэшируемость: скор зависит только от пары (пользователь, пост). Воспроизводимость: порядок не зависит от разбиения на батчи. Объяснимость: причина позиции не содержит слов «рядом оказался другой пост». Скорость: исчезает квадратичный член по кандидатам.
Цена — модель не видит слейт и не может учесть взаимное влияние постов. Эту задачу выносят в отдельный шаг переранжирования после скоринга.
Почему конверсионные головы обучаются только на кликнутых постах?
Потому что «конверсии не было» на посте без клика не означает отказа — у пользователя не было возможности совершить действие. Обучая на таких примерах, мы учим модель, что отсутствие возможности равно отрицательному ответу, и получаем систематическое занижение.
Это классический сдвиг выборки, тот же, ради которого придумали ESMM. Здесь выбран путь маскирования лосса: голова получает градиент только на подвыборке с кликом, при этом общее тело модели видит все примеры, так что представления учатся на полных данных.
Зачем разные оптимизаторы для плотных слоёв и эмбеддингов?
У них различается частота получения градиента на порядки. Плотная матрица обновляется на каждом примере батча; конкретная строка таблицы эмбеддингов — только когда её айтем попал в батч.
Поэтому эмбеддингам нужен большой шаг и адаптация по каждой строке отдельно — построчный Adagrad со скоростью 0.28, тогда как плотная часть учится Muon со скоростью 7.1e-4, почти в четыреста раз меньшей. Единый оптимизатор либо задушит эмбеддинги малым шагом, либо развалит плотную часть большим.
Отдельная тонкость — период полураспада статистики Adagrad в 2500 шагов: без забывания знаменатель растёт монотонно, эффективный шаг уходит в ноль, и модель перестаёт адаптироваться к изменившемуся поведению.
Что предсказывается регрессией, а не классификацией, и зачем?
Время задержки, время после клика, активные секунды — восемь непрерывных величин. Их нельзя загонять в бинарную постановку, не потеряв главное: разницу между «посмотрел три секунды» и «посмотрел тридцать». Любой порог эту разницу стирает.
Платят за это тяжёлым хвостом распределения и необходимостью согласовывать масштаб с вероятностями в общей сумме. Первое лечится метриками, устойчивыми к выбросам, второе — маленьким весом при непрерывном слагаемом в формуле скора.
Зачем добавлять шум в признак «час дня»?
Потому что час — искусственная дискретизация непрерывного времени. Разница между 13:59 и 14:01 для поведения нулевая, но для признака это переход между значениями, и модель выучивает резкие границы на круглых часах — артефакт разметки, а не свойство пользователей.
Десятипроцентное дрожание размывает границы и вынуждает учить плавную зависимость. Тот же мотив, что у кусочно-линейного кодирования непрерывных признаков, только приём грубее и универсальнее: он не требует менять архитектуру.
Ранкер вдвое шире ретривера. Почему не наоборот?
Из-за числа объектов, которые каждый должен обработать. Ретривер выдаёт эмбеддинги для 28 миллионов постов; удорожание его башни умножается на этот корпус. Ранкер работает с сотнями кандидатов на запрос, и стоимость лишней ширины несопоставимо меньше.
Это общий принцип многостадийных систем, который мы разбирали в главе «Многостадийная воронка»: чем дальше по воронке, тем меньше объектов и тем дороже допустимая модель. Стадия отбора обязана быть простой, стадия ранжирования может себе позволить сложность.
История в модели двусторонняя, без причинной маски. Не утечка ли это?
Нет. Утечка была бы, если бы модель при предсказании использовала информацию из будущего относительно момента предсказания. Здесь вся история — прошлое по отношению к оцениваемым кандидатам; внутри неё событию из середины смотреть на более позднее совершенно законно.
Причинная маска нужна авторегрессионным моделям, предсказывающим следующий элемент последовательности. Здесь задача другая: закодировать историю целиком, а предсказание снимается с позиций кандидатов, которые стоят после всей истории.
Шпаргалка одним экраном
Раскладка
2 токена профиля + 1022 события истории + 64 кандидата в одной последовательности.
Маска
Ключ из истории ИЛИ ключ — сам запрос. Кандидаты не видят друг друга.
Что даёт
Кэшируемость, воспроизводимость, объяснимость, отсутствие квадратичного члена.
Чем платит
Модель не видит слейт. Разнообразие наводится отдельным шагом после скоринга.
Головы
Дискретные — вероятности действий. Непрерывные — время задержки и активные секунды.
Маски лосса
Конверсии — только на кликнутых. Негативная обратная связь — только на реальных показах.
Оптимизаторы
Muon для плотных слоёв (7.1e-4), построчный Adagrad для эмбеддингов (0.28) с забыванием.
Размеры
2560 против 1024 у ретривера; 20 голов запросов на 4 головы ключей.
Признаки
Профиль, момент, возраст поста, счётчики, двусторонняя подписка. Час дня — с дизерингом.
Первоисточники
- recsys_model.py — модель, сегментные идентификаторы, маски лосса по головам.
- ranker_attention_v2.py — ядро внимания и та самая строка маски.
- xrecsys.py — продовый конфиг: размеры, признаки, оптимизаторы.
- ranker_attention_fa4.py — блочно-разреженная раскладка внимания для новых GPU.
- Курс: глава «Трансформеры над историей» о трансформерах над историей, глава «ESMM и сдвиг выборки» об ESMM и сдвиге выборки, глава «Списочные лоссы» о списочных постановках.