RecSys · учебник
Тренажёр Виджеты Повторение О проекте Все главы ← Лоссы Взаимодействия →

Часть III · Ранжирование · глава 11 из 19

Признаки

Лосс выбран — осталось решить, что подавать на вход. В рекомендациях это не рутина: здесь признаковое пространство устроено так, что оно само диктует выбор архитектуры. Разбираем, почему градиентный бустинг проигрывает именно здесь, как устроен эмбеддинг-слой изнутри и что делают с вещественными признаками, прежде чем показать их сети.

Что унести из главы
  • Дерево не умеет складывать координаты. Чтобы воспроизвести одно скалярное произведение в 16 измерениях, осевому дереву нужно 4.29 · 109 листьев; линейному слою — 16 умножений.
  • Эмбеддинг — это one-hot, умноженный на матрицу. Никакой отдельной магии нет, а target encoding оказывается частным случаем при \(d = 1\).
  • Кодирование может уничтожить информацию безвозвратно. Два айтема с одинаковым средним CTR и разной аудиторией после target encoding неразличимы — минус 50% кликов, и никакой лосс их не вернёт.
  • Построчный оптимизатор экономит 20.4 ГБ на одной таблице, а sparse-градиенты трогают 0.041% параметров. Инженерия эмбеддинг-слоя — половина работы.

1. Почему здесь проигрывает бустинг

Вопрос звучит на собеседованиях постоянно и в лоб: «зачем вам нейросеть, возьмите CatBoost». Ответ есть, и он состоит из трёх конкретных пунктов, а не из общих слов про глубокое обучение.

Причина 1: высокая кардинальность

Как дерево кодирует категорию? Есть два способа, и оба ломаются.

Во что обходится склейка

Два айтема и два одинаковых по размеру сегмента аудитории:

АйтемCTR в сегменте ACTR в сегменте BОбщий CTR
X0.200.000.100
Y0.000.200.100

После target encoding оба айтема — одно и то же число 0.100. Модель физически не может их различить.

Показывая каждому сегменту подходящий айтем, получаем CTR 0.200. Не различая айтемы — 0.100. Это потеря 50% кликов, и никакой лосс её не вернёт: информация уничтожена на этапе кодирования признака, до всякого обучения.

Числа воспроизводятся скриптом _tools/features_demo.py в этом репозитории.

В нейросети та же категория кодируется естественно — через эмбеддинг, и двух измерений хватает, чтобы развести X и Y.

Причина 2: неструктурированные данные

Тексты, изображения, аудио, история пользователя. По ним можно нагенерировать признаки для бустинга, но работает это хуже. Можно построить нейросетевые модели и извлечь из них признаки — например, скалярное произведение из двухбашенной модели. И тут выясняется вещь поинтереснее.

Причина 3: эмбеддинг на входе в дерево

Дерево не умеет складывать координаты

Координаты эмбеддинга по отдельности не значат ничего: семантика заложена в вектор целиком. А каждый сплит в дереве анализирует ровно одну координату.

Чтобы различить \(k\) уровней по каждой из \(d\) координат, осевому дереву нужна решётка из \(k^d\) ячеек:

\(d\)листьев при \(k=2\)листьев при \(k=4\)умножений у линейного слоя
4162564
82566.55e+048
166.55e+044.29e+0916
324.29e+091.84e+1932

При \(d=16\) и четырёх уровнях на координату это 4.29 · 109 листьев — больше, чем сэмплов в любом датасете, и глубина 32 уровня. Линейный слой делает то же самое за 16 умножений и 15 сложений.

Числа воспроизводятся скриптом _tools/features_demo.py.

А ведь 16 — скромная размерность: реальные эмбеддинги 64–256-мерные. Это не «бустинг чуть хуже», это принципиальная несовместимость представления с моделью.

Когда бустинг всё-таки лучше

Честный ответ на собеседовании обязан содержать и эту половину. Главный минус нейросетей — высокий порог входа: нужно уметь обучать сети, работать с GPU, вести распределённое обучение и внедрять всё это в рантайм.

Чек-лист «глубокое обучение оправдано»:

  • много данных;
  • важны высококардинальные признаки;
  • важны неструктурированные данные;
  • много сигналов сразу — лайки, досмотры, покупки: в одну модель бустинга их складывать мучительно, а отдельные модели дороги;
  • нужно быстро дообучаться под дрейф: бустинг приходится перестраивать целиком, сеть можно дообучать инкрементально.

Если не выполняется ни один пункт — CatBoost на агрегатах будет и дешевле, и лучше. Это не оговорка из вежливости: значительная часть рекомендательных задач именно такова.

Общий фон здесь — bitter lesson Саттона: выигрывают методы, которые масштабируются вычислениями, а не человеческой изобретательностью. NLP и зрение этот переход уже прошли; рекомендации — последняя строчка, где он ещё идёт.

2. Категориальные признаки

Категориальный признак задаётся множеством значений, из которых на каждом сэмпле реализуется одно. Кардинальность в рекомендациях разнится от 2 (утро/вечер) до миллиардов (идентификатор айтема).

Эмбеддинг = one-hot × матрица

Вывод, который стоит уметь показать на доске за двадцать секунд. Представим признак one-hot вектором \(e_i \in \{0,1\}^n\) и применим линейный слой без свободного члена, то есть умножим на матрицу \(W \in \mathbb{R}^{n\times d}\):

$$ e_i W = \bigl(W_{ij}\bigr)_{j=1}^{d} $$

Это просто \(i\)-я строка матрицы. Отсюда два вывода:

  • \(W\) — матрица обучаемых эмбеддингов, где каждому значению признака сопоставлен свой вектор;
  • \(e_i W\) — операция embedding lookup: вместо умножения на разреженный вектор достаём нужную строку. Никакой отдельной «магии эмбеддингов» нет — есть оптимизация умножения на one-hot.

И приятное следствие, которое любят спрашивать: при \(d = 1\) модель может выучить средний CTR значения, то есть target encoding — частный случай эмбеддинга размерности один. Всё, что даёт эмбеддинг сверх него, — это дополнительные измерения, в которых помещается «кому именно нравится».

Какой размер эмбеддинга брать

Одинаковый размер для всех признаков неоптимален. Он должен зависеть от кардинальности — чтобы описать признак из двух значений, широкий вектор не нужен — и от информативности, то есть от полезности признака для задачи.

Две оценки и разрыв между ними

Оценка снизу, информационная. Пусть эмбеддинг состоит из \(d\) элементов по \(s\) бит. Всего кодируется \(2^{ds}\) значений, значит чтобы различить \(n\) значений, достаточно

$$ d\,s = \log_2 n $$

Практическая эвристика Google: \(d = 6\sqrt[4]{n}\).

Кардинальность\(\log_2 n\), бит\(6\sqrt[4]{n}\)Память при float32
1e+0310.0340.00 ГБ
1e+0516.61070.04 ГБ
1e+0723.333713.50 ГБ
1e+0929.910674267.87 ГБ

Числа воспроизводятся скриптом _tools/features_demo.py.

Разрыв огромный и он содержательный. При \(n = 10^7\) информационная оценка — 23 бита, меньше одного числа float32: чтобы просто различить значения, хватило бы \(d = 1\). Вся остальная ёмкость идёт под семантику, а не под идентификацию.

Эвристику не берут буквально

Строка про \(n = 10^9\) в таблице выше — это 4.3 терабайта на один признак. В проде типичные размерности 32–128, а не 337 и тем более не 1067.

Эвристика полезна как ориентир порядка величины и как напоминание, что размер должен расти с кардинальностью. Более честный путь — dimension optimization: подбирать размеры эмбеддингов прямо в процессе обучения, а не задавать константой.

3. Инженерия эмбеддинг-слоя

Здесь начинается то, что отличает разговор человека, который такое делал, от пересказа статьи. Три приёма, о которых спрашивают.

ПриёмПроблемаРешение
Batched lookup отдельный лукап на каждый признак — это много маленьких операций на GPU, а у каждой есть накладные расходы на запуск ядра объединить эмбеддинги всех признаков в одну матрицу и делать единый лукап со сдвигом, при котором каждый признак попадает в свою область
Sparse gradients на батче встречается лишь малая часть строк, у остальных градиент ровно нулевой sparse-оптимизатор обновляет только строки с ненулевым градиентом; при распределённом обучении нулевые градиенты не пересылаются вовсе
Rowwise optimizer Adam хранит два момента на каждый параметр — это утроение памяти хранить две статистики не на параметр, а на строку: память падает почти до размера самой таблицы
Сколько это стоит на одной таблице

Признак кардинальности 10 млн с эмбеддингом 256:

сама таблица во float3210.2 ГБ
таблица + два момента Adam30.7 ГБ (в 3 раза больше)
таблица + построчные статистики10.32 ГБ — на 0.8% больше таблицы
экономия20.4 ГБ, или в 3.0 раза

А про разрежённость: батч на 4096 сэмплов трогает не больше 4096 строк из 10 млн — это 0.0410% таблицы. Пересылать и обновлять надо ровно эту долю параметров, всё остальное — нули.

Числа воспроизводятся скриптом _tools/features_demo.py.

Тонкость про learning rate

Параметры со sparse-градиентом обновляются сильно реже плотных: строка редкого значения увидит градиент несколько раз за эпоху, а веса MLP — на каждом батче.

Поэтому для эмбеддингов обычно увеличивают learning rate относительно остальной сети. Это не тюнинг ради тюнинга, а компенсация разной частоты обновлений — и частая причина того, что «эмбеддинги почему-то не учатся».

Отдельная большая тема — что делать, когда таблицы не помещаются на GPU: шардирование (по координатам или по значениям признака), выгрузка на CPU с асинхронным обновлением и хеширование. Хеш-трюк и мультихэш разобраны в главе 8 вместе с арифметикой коллизий, здесь не повторяем.

4. Вещественные признаки

Большая часть признаков в рекомендациях — вещественные: счётчики, статистики по множествам, временные статистики вроде «время с последнего покупки в категории», характеристики пользователя и айтема.

Зачем их вообще обрабатывать

Три причины, и все три специфичны именно для нейросетей:

  • Масштаб. Сети чувствительны к масштабу: признаки с большими значениями дают большой вклад в градиент и доминируют при обучении.
  • Выбросы есть всегда, и они либо ломают обучение, либо сильно ухудшают качество.
  • Пропуски сеть не умеет обрабатывать без отдельного решения.

Обратите внимание: у деревьев ни одной из этих проблем нет — они инвариантны к монотонным преобразованиям и умеют обрабатывать пропуски штатно. Вся эта возня возникает как плата за переход к сетям, и на собеседовании это хорошо назвать вслух.

Пять преобразований

Что применяют и зачем

1. Логарифм. \(F(x) = \log(x+1)\) — уменьшает скошенность. В реальных данных часто встречаются логнормальные распределения: разница между 1 и 2 важна, а между 100001 и 100002 — уже нет. Логарифм ровно это и выражает.

2. Сигмоида. \(F(x) = \sigma(\gamma x + \beta)\) — зажимает признак в ограниченном диапазоне вместе с выбросами. Важно нормализовать значения перед сигмоидой, иначе производная на краях близка к нулю и градиенты затухают. Можно применить несколько сигмоид с разными обучаемыми \(\gamma, \beta\).

3. Функция распределения. \(F(x) = P(X \le x)\) — сопоставляет значению его квантиль и приводит признак к равномерному на \([0,1]\). Убирает и скошенность, и выбросы одним движением. Считать эмпирическую CDF по всем данным дорого, поэтому используют квантильную аппроксимацию по опорным точкам.

4. Периодические функции.

$$ F(x) = \bigl[\sin(2\pi c_1 x),\, \cos(2\pi c_1 x),\, \sin(2\pi c_2 x),\, \cos(2\pi c_2 x), \dots\bigr] $$

Для временных признаков прежде всего. Коэффициенты \(c_i\) могут быть фиксированными (частоты «час», «день», «неделя») или обучаемыми. Механизм тот же, что у позиционных эмбеддингов в трансформере.

5. Квантизация. \(F(x) = \sum_i \mathbb{1}[a_i < x \le b_i]\cdot i\) — превращает вещественный признак в категориальный по квантильным бинам. Недостаток принципиальный: теряется отношение порядка — и между бинами, и внутри бина.

Почему «час как число от 0 до 23» — плохая идея

В сыром виде 23 и 0 оказываются максимально далёкими значениями, хотя это соседние часы. Синус и косинус одной частоты кладут время на окружность:

Пара часов\(|h_1 - h_2|\) сыроерасстояние на окружности
23 и 0230.2611
23 и 12111.9829
0 и 110.2611
11 и 1320.5176

Расстояние между 23 и 0 становится ровно таким же, как между 0 и 1 — 0.2611, минимальным из возможных. А самой далёкой парой закономерно оказывается 23 и 12, то есть противоположные точки суток.

Числа воспроизводятся скриптом _tools/features_demo.py.

Несколько частот дают несколько окружностей разного масштаба, то есть модель видит и час, и день недели, и сезон одновременно.

Кусочно-линейное кодирование

Квантизация ловит нелинейность, но выдаёт ступеньки: внутри бина значения неразличимы, на границе прогноз прыгает. Идея PLE — кодировать не только номер бина, но и положение внутри него.

Как устроен вектор

Значение \(x\) кодируется вектором длины \(T\) по числу бинов: слева от текущего бина единицы, справа нули, и ровно одна компонента дробная — доля пройденного бина.

$$ \mathrm{PLE}(x)_t = \begin{cases} 1, & x \ge b_t \\[2pt] \dfrac{x - b_{t-1}}{b_t - b_{t-1}}, & b_{t-1} \le x < b_t \\[2pt] 0, & \text{иначе} \end{cases} $$

Тогда \(\mathrm{Linear}(\mathrm{PLE}(x)) = v_0 + \sum_t e_t v_t\) даёт непрерывную кусочно-линейную функцию: нелинейность как у one-hot, но без разрывов и без потери разрешения внутри бина.

Насколько это точнее при том же числе параметров

Приближаем \(f(x) = x^2\) на \([0,1]\) линейной моделью поверх кодирования:

БиновMSE, one-hot по бинамMSE, кусочно-линейноеВо сколько раз лучше
22.649e-022.081e-0312.7
46.893e-031.301e-0453.0
81.741e-038.130e-06214.1
164.358e-045.081e-07857.6

Числа воспроизводятся скриптом _tools/features_demo.py.

Обратите внимание не только на разрыв, но и на то, как он растёт с числом бинов: у кусочно-постоянного кодирования ошибка падает как \(T^{-2}\), у кусочно-линейного — как \(T^{-4}\). Добавлять бины кусочно-линейному кодированию вчетверо выгоднее.

Что здесь надо увидеть
  1. Сравните три кривые. Сырой признак в линейной модели даёт только прямую. One-hot по бинам ловит нелинейность, но выдаёт ступеньки.
  2. Двигайте \(x\) внутри одного бина: у one-hot выход стоит на месте, у PLE плавно едет. Это главное отличие и главный аргумент.
  3. Увеличивайте число бинов: кривая приближает всё более сложную зависимость — ценой \(T\) параметров на признак.

Что сказать на собесе: «PLE — компромисс между сырым признаком и биннингом: нелинейность как у one-hot, но без разрывов и без потери разрешения внутри бина».

Диагностика, которую стоит завести

Практический приём из работы Airbnb про глубокое обучение в поиске: после преобразований смотреть на гладкость распределения признака.

Гладкое, близкое к нормальному распределение — признак того, что преобразование выбрано удачно. Горбы, тяжёлые хвосты и всплески на конкретных значениях — сигнал, что с признаком что-то не так: скорее всего, там прячется заглушка вместо пропуска, обрезка сверху или склейка разных сущностей в одну колонку.

Это дешёвая проверка, которая ловит целый класс ошибок в данных до обучения.

5. Выходы других моделей как признаки

Третий тип входов — эмбеддинги, пришедшие извне: контентные векторы, выходы двухбашенной модели, результаты предобучения. С ними делают три вещи.

Разбор: ранжирование YouTube

Главный признак ранжирующей модели YouTube — идентификатор видео. Раньше для него использовали хеш-трюк; заменили на semantic IDs, обучив RQ-VAE поверх контентных эмбеддингов видео. Результат — заметный прирост на холодном срезе.

Самая интересная деталь, и именно её стоит уметь объяснить: прямое использование контентного эмбеддинга работает хуже, чем дискретизованный semantic ID.

Почему так? Плотный контентный вектор — «мягкий» признак: по нему модель обобщает, но не может запомнить специфику конкретного видео. Дискретный код даёт и то и другое сразу: общий префикс отвечает за обобщение, полный код — за меморизацию. Дискретизация здесь не потеря информации, а разделение двух режимов, которые в плотном векторе смешаны.

Обратная сторона того же аргумента — проблема хеширования: получаемые идентификаторы не несут семантики, и для нового айтема мультихэш выдаёт неизвестную модели комбинацию. Полный упор в меморизацию и ноль обобщения.

Вопросы с собеседований

Почему в рекомендациях не хватает градиентного бустинга?

Три конкретные причины. Высокая кардинальность: one-hot на миллион значений не построить, а target encoding теряет семантику — два айтема с одинаковым средним CTR и разной аудиторией склеиваются в одно число, и это стоит 50% кликов в простом примере с двумя сегментами.

Неструктурированные данные: тексты, картинки, история — по ним можно нагенерировать признаки, но работает хуже.

И главное: эмбеддинги на входе в дерево работают плохо принципиально. Координаты по отдельности ничего не значат, а сплит смотрит на одну координату; чтобы различить 4 уровня по каждому из 16 измерений, нужно 4.29 млрд листьев, тогда как линейный слой обходится 16 умножениями.

Плюс два практических аргумента: много сигналов сразу и дрейф — бустинг надо перестраивать целиком, сеть можно дообучать.

А когда бустинг наоборот лучше?

Когда не выполняется ни один пункт чек-листа: много данных, важные высококардинальные признаки, неструктурированные данные, много сигналов, потребность в быстром дообучении. Если ничего из этого нет, CatBoost на агрегатах будет и дешевле, и лучше.

Главный минус сетей — порог входа: GPU, распределённое обучение, инференс в рантайме. Это реальная стоимость, и делать вид, что её нет, на собеседовании не стоит.

Покажите, что эмбеддинг — это линейный слой.

Представим категорию one-hot вектором \(e_i \in \{0,1\}^n\) и умножим на матрицу \(W \in \mathbb{R}^{n \times d}\) без свободного члена. Результат \(e_i W\) — это \(i\)-я строка \(W\). Значит \(W\) и есть таблица эмбеддингов, а лукап — просто оптимизация умножения на разреженный вектор.

Полезное следствие: при \(d = 1\) модель выучивает средний CTR значения, то есть target encoding — частный случай эмбеддинга размерности один. Всё, что даёт эмбеддинг сверх этого, — измерения, в которых помещается «кому именно нравится».

Как выбрать размер эмбеддинга?

Он должен зависеть от кардинальности и от информативности признака; одинаковый размер для всех неоптимален.

Информационная оценка: \(d\cdot s = \log_2 n\) бит. Эвристика Google: \(d = 6\sqrt[4]{n}\). Разрыв между ними огромный — при \(n=10^7\) это 23 бита против d = 337 — и он содержательный: почти вся ёмкость идёт под семантику, а не под идентификацию.

Буквально эвристику не берут: при \(n=10^9\) она даёт 1067 и 4.3 терабайта на признак. В проде типичные размерности 32–128, а честный путь — подбирать размерности прямо в обучении.

Какие оптимизации эмбеддинг-слоя вы знаете?

Batched lookup: объединить таблицы всех признаков в одну матрицу и делать единый лукап со сдвигом — иначе получается много мелких операций на GPU с накладными расходами на запуск ядра.

Sparse gradients: батч на 4096 сэмплов трогает не больше 4096 строк из 10 млн, то есть 0.041% таблицы; остальные градиенты — нули, их не надо ни обновлять, ни пересылать между картами. Важная тонкость: для таких параметров стоит увеличивать learning rate, потому что обновляются они сильно реже плотных весов.

Rowwise optimizer: хранить статистики Adam не на параметр, а на строку. Таблица 10 млн × 256 занимает 10.2 ГБ, с моментами Adam — 30.7 ГБ, а с построчными статистиками 10.32 ГБ, то есть на 0.8% больше таблицы.

Плюс шардирование, выгрузка на CPU и хеш-трюк, когда таблицы не помещаются на GPU.

Зачем преобразовывать вещественные признаки и как?

Затем, что сети чувствительны к масштабу (большие значения доминируют в градиенте), ломаются на выбросах и не умеют обрабатывать пропуски. У деревьев ни одной из этих проблем нет — это плата за переход к сетям.

Пять преобразований: логарифм против скошенности; сигмоида, зажимающая выбросы (с нормализацией до неё, иначе затухают градиенты); функция распределения, приводящая признак к равномерному; периодические функции для времени; квантизация по квантилям.

У квантизации принципиальный недостаток — теряется отношение порядка и между бинами, и внутри бина. Его чинит кусочно-линейное кодирование.

Почему час нельзя подавать числом от 0 до 23?

Потому что в сыром виде 23 и 0 — максимально далёкая пара из возможных, хотя это соседние часы. Модель вынуждена тратить ёмкость на то, чтобы выучить склейку на границе суток.

Пара синус-косинус одной частоты кладёт время на окружность: расстояние между 23 и 0 становится 0.2611 — ровно таким же, как между 0 и 1, и минимальным из возможных. Самой далёкой парой оказывается 23 и 12, то есть противоположные точки суток, как и должно быть.

Несколько частот дают несколько окружностей разного масштаба — час, день недели, сезон одновременно. Механизм тот же, что у позиционных эмбеддингов в трансформере.

Что такое PLE и чем оно лучше биннинга?

Кусочно-линейное кодирование: вектор длины T, где слева от текущего бина единицы, справа нули, и ровно одна компонента дробная — доля пройденного бина. Линейный слой поверх такого вектора даёт непрерывную кусочно-линейную функцию.

Отличие от one-hot по бинам: внутри бина выход не константа, а линейно едет. На приближении \(x^2\) при 16 бинах MSE отличается в 857 раз. И важнее разрыва то, как он растёт: у кусочно-постоянного кодирования ошибка падает как \(T^{-2}\), у кусочно-линейного — как \(T^{-4}\).

Почему дискретизованный semantic ID работает лучше плотного контентного вектора?

Потому что он разделяет два режима, которые в плотном векторе смешаны. Плотный контентный вектор — «мягкий» признак: по нему модель обобщает, но не может запомнить специфику конкретного объекта. Дискретный иерархический код даёт и то и другое: общий префикс отвечает за обобщение на похожие объекты, полный код — за меморизацию конкретного.

Это ровно тот случай, когда дискретизация не теряет информацию, а структурирует её. Обратная сторона того же аргумента — хеш-трюк: его идентификаторы семантики не несут, поэтому для нового объекта получается неизвестная модели комбинация, полный упор в меморизацию и ноль обобщения.

Шпаргалка одним экраном

Против бустинга

Кардинальность, неструктурированное, эмбеддинги. При d=16 и k=4 дереву нужно 4.29e9 листьев.

За бустинг

Если не выполнен ни один пункт чек-листа — CatBoost на агрегатах дешевле и лучше.

Эмбеддинг

one-hot × W = строка W. Target encoding — тот же эмбеддинг при d = 1.

Размерность

Снизу \(\log_2 n\) бит, эвристика \(6\sqrt[4]{n}\). Разрыв — ёмкость под семантику, не под ID.

Инженерия слоя

Batched lookup, sparse-градиенты (0.041% строк), построчный Adam: 30.7 → 10.32 ГБ.

Вещественные

log1p, сигмоида, CDF, sin/cos, квантизация. Масштаб, выбросы, пропуски — проблемы сетей, не деревьев.

Время

23 и 0 в сыром виде — самая далёкая пара. На окружности 0.2611, как 0 и 1.

PLE

Нелинейность без ступенек. Ошибка падает как \(T^{-4}\) вместо \(T^{-2}\).

Первоисточники