Негативное сэмплирование и контрастное обучение — от word2vec до CLIP

Хрестоматийная версия word2vec опирается на выходной слой softmax, который превращает сырые оценки модели в вероятность для каждого слова словаря, — и именно отсюда берётся проблема масштабирования. При обучении функция потерь использует ровно одну вероятность на пару, P(target | center). Но softmax определяет её как долю от суммы по всему словарю:

P(target) = exp(score_target) / Σ_w exp(score_w)

Знаменатель суммирует по всем V словам, поэтому, чтобы получить одно нужное вам число, приходится вычислить все V оценок — не потому что они вам нужны, а потому что они нужны нормировщику. Уберите любую отдельную оценку — и нормировка сломается.

При словаре V = 10⁶ слов и размерности эмбеддинга d = 300 оценивание центрального слова против каждого слова словаря — это матричное умножение V × d, 300 миллионов умножений-сложений, плюс миллион экспонент для знаменателя, и всё это на одну обучающую пару.

Чтобы почувствовать масштаб, сравним вычисления на один пример с MNIST:

                          INPUT      HIDDEN     OUTPUT     OUTPUT-LAYER MATMUL
MNIST                     784        128        10         128 × 10  = 1,280 ops
word2vec  (V = 10⁶)       V → 1      300        V          300 × 10⁶ = 300,000,000 ops

MNIST — задача классификации на 10 классов; word2vec — задача на миллион классов, по одному выходному нейрону на слово словаря. Любая цена в выходном слое — веса, матричное умножение, softmax — растёт линейно с этим числом классов.

На самом деле word2vec никогда не считал softmax по всем V. В статье с самого начала было введено негативное сэмплирование — оно избавляет от необходимости в нормировщике и сосредотачивается на отдельных оценках. Каждая пара (center, w) перестаёт быть одной V-классовой классификацией — оценкой того, насколько вероятно w относительно всех остальных слов словаря, — и становится самостоятельной оценкой того, насколько вероятно, что именно эта пара настоящая. Для каждой выбранной пары модель выдаёт одну сигмоидную оценку σ(v_c · v'_w) — её тянут вверх для настоящих пар и вниз для случайных. Поскольку каждое суждение самостоятельно, функция потерь — это обычная сумма попарных слагаемых, и именно эта разделимость и есть причина, по которой можно оценить лишь k+1 строк E' и проигнорировать остальные: другие слова вообще не входят в функцию потерь на этом шаге.

Механически это резко сокращает шаг оценивания. Там, где softmax делает одно матричное умножение на всю выходную матрицу, чтобы получить все оценки разом, негативное сэмплирование делает несколько отдельных скалярных произведений — по одному на каждый из k случайно выбранных негативов плюс одно на настоящую пару, всего k+1. Остальные V − (k+1) строк E' не читаются, ни на что не умножаются и на этом шаге не обновляются. Каждое из k+1 скалярных произведений проходит через свою собственную сигмоиду, независимо, — их не связывает никакой знаменатель. Функция потерь для пары раскладывается в сумму k+1 независимых слагаемых: та же машинерия максимального правдоподобия, что и у softmax, только над k+1 независимыми бернуллиевскими величинами вместо одного мультиномиального распределения по всему словарю.

Этот механизм называется контрастным обучением — притягивать парное друг к другу, расталкивать случайное — и его можно применить к чему угодно, что поддаётся встраиванию в векторное пространство. Две его разновидности, с которыми вы могли столкнуться: плотные ретриверы вроде DPR, движка RAG, которые обучаются на парах «запрос — релевантный пассаж» как позитивах и случайных пассажах как негативах; и модели вознаграждения в RLHF, где роль позитива играют предпочтённые ответы, а негатива — отвергнутые.

От пар к негативам

Обучающие данные word2vec — пары (center, context) из скользящего окна по корпусу: the cat sat on the mat порождает (sat, cat), (sat, on), (sat, the) и так далее — миллиарды подлинных совместных встречаемостей.

Негативное сэмплирование оставляет пары в точности теми же, но вместо того, чтобы просто максимизировать правдоподобие настоящего соответствия (center, context), добавляет k случайных пар на шаг в качестве противовеса — подстраивая веса так, чтобы их правдоподобие, наоборот, минимизировалось.

Оба типа пар делят один и тот же центр, но по-разному выбирают слово-напарника:

  • Настоящие пары — идём по корпусу и берём слова, которые реально появляются в контекстном окне каждого центра. Напарник задаётся тем, что действительно встречается вместе в корпусе (совместным распределением): (sat, cat) возникает потому, что так и было.
  • Случайные пары — оставляем тот же центр, но тянем напарника из того, как часто каждое слово встречается в корпусе (униграммного распределения), полностью игнорируя совместную встречаемость. Получаются пары вроде (sat, banana) — оба слова настоящие, но рядом они никогда не стояли.

Негативное сэмплирование сохраняет скелет полного softmax — три стадии прямого прохода, функция потерь, градиент — и меняет только то, что происходит внутри каждой. Единственная по-настоящему новая операция — это сэмплирование: вытянуть k негативных слов для каждой пары из униграммного распределения (тот самый розыгрыш случайной пары выше). Всё остальное — это полный softmax, ограниченный по охвату: поиск по-прежнему читает одну строку из E, а шаг оценивания по-прежнему скалярно умножает центр на строки E' — просто на k+1 из них (цель плюс k негативов) вместо всех V.

k+1 скалярных произведений вместо V — та же самая операция v_c · v'_w, что выполняет полный softmax, только вычисленная для слов, попавших в функцию потерь, а не для всего словаря. Полного матричного умножения v_c @ E', которое требует softmax, не происходит вовсе.

Сырое скалярное произведение v_c · v'_w может быть любым вещественным числом — положительным, отрицательным, большим, малым, — но функции потерь нужна вероятность от 0 до 1: «насколько вероятно, что эта пара настоящая?». К ней нас приводит сигмоида σ.

Получив оценки, мы можем превратить каждую в вероятность с помощью сигмоиды. σ сжимает любую вещественную оценку в (0, 1):

скалярное произведение v_c · v'_wσмодель говорит
большое положительное~1«настоящая пара»
~00.5не уверена
большое отрицательное~0«случайная пара»

Для каждой настоящей пары мы хотим, чтобы σ ползла к 1; для каждой случайной — к 0; обучение толкает скалярные произведения в эти стороны.

На той же игрушке из пяти слов из статьи про word2vec виджет ниже проигрывает этот шаг оценивания. Выберите обучающую пару (center, target), переключите, какие слова взяты как негативы, а затем пошагово пройдите k+1 скалярных произведений — score[w] = v_c · v'_w, разложенное слагаемое за слагаемым, а потом через сигмоиду. Оцениваются только цель и негативы; остальные строки E' остаются серыми и не читаются.

scoring one pair · k+1 dot products, not V
training pair
negatives — sampled
all 3 dot products done — loss is defined
E (V=5 × d=3)
row sat = v_c
cat
0.21
-0.43
0.15
mat
0.07
0.62
-0.31
on
-0.55
0.18
0.40
sat
0.33
-0.27
0.84
the
-0.12
0.49
-0.06
E' (V=5 × d=3)
target + k negatives — the rest unread
cat
0.45
0.62
-0.20
mat
-0.31
0.15
0.48
on
0.18
-0.40
0.27
sat
-0.22
0.33
0.11
the
0.07
0.55
-0.39
scores → σ target + k negatives
Stage 2 — score · k+1 dot products, not V
cat
mat
on
sat
the
-0.19
0.26
0.39
·
skip
Stage 3 — sigmoid · independent σ, no softmax
cat
mat
on
sat
the
0.45
0.56
0.60
·
each score through its own σ — no shared denominator; target → 1, negatives → 0.
loss −Σ log σ
k+1 terms
1.95
wordrolescoreσ(score)direction
onpositive+0.3940.597→ pushed toward 1
catnegative-0.1870.453→ pushed toward 0
matnegative+0.2600.565→ pushed toward 0

Там, где полный softmax прогоняет V оценок через одно нормированное распределение, негативное сэмплирование пропускает каждую из k+1 оценок через собственную σ независимо: σ(v_c · v'_w) стоит сама по себе как оценка модели, что пара (center, w) настоящая, без общего знаменателя. k+1 независимых вероятностей вместо одного распределения по всему словарю.

Как сэмплируются негативы?

Изначально word2vec предлагал сэмплировать негативы из распределения униграммных частот — брать случайные слова из словаря с весом по частоте их появления. Однако современные контрастные системы — CLIP, DPR, Sentence-BERT — полностью обходят это стороной и берут в качестве негативов другие примеры из того же обучающего батча. Такой подход называется внутрибатчевыми негативами (in-batch negatives). Разберём кратко оба.

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

Но чистое взвешивание по частоте означало бы, что ~98% негативов — это слова, которые встречаются рядом со всем подряд и не несут различающей информации: так называемые стоп-слова вроде the и and. Золотую середину можно получить сглаживанием частот: всё ещё смещённое к частым словам (так что большинство негативов реалистичны), но достаточно приглушённое, чтобы содержательные слова вроде cat, mat, king попадались достаточно часто и учили настоящим смысловым различиям.

Каждое слово сэмплируется с вероятностью, пропорциональной тому, как часто это слово встречается в корпусе, считая его отдельно (его униграммной частоте). Оригинальная статья эмпирически выяснила, что лучше всего работает возведение этих счётчиков в степень 3/4 перед нормировкой: значения от 0.5 до 1.0 дают приличные результаты, а 0.75 попадает в самую точку:

P(w)=count(w)0.75wcount(w)0.75P(w) = \frac{\text{count}(w)^{0.75}}{\sum_{w'} \text{count}(w')^{0.75}}

P(w) здесь — вероятность того, что слово w будет вытянуто как негативный пример. Форма — счётчики, нормированные своей суммой, — это тот же рецепт, что использует softmax; возведение счётчиков в 0.75 перед нормировкой — это приём температурного масштабирования, который сглаживает распределение. В числителе — count(w)^0.75 каждого слова; знаменатель суммирует их по словарю, чтобы вероятности давали в сумме 1.

Когда распределение готово, сэмплирование тривиально: вычислите P(w) для каждого слова словаря по формуле выше, а затем для каждой позитивной пары (center, context) вытяните k случайных слов из этого распределения — взвешенный бросок кости по словарю, повторённый k раз, где слова с бо́льшим P(w) выпадают чаще.

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

Обучение идёт батчами из N настоящих пар (query, positive)(Q1, P1), …, (QN, PN). Каждый Qi действительно соответствует своему Pi (запрос и релевантный ему пассаж, изображение и его подпись и т. д.). В качестве негативов для Qi достаточно взять остальные позитивы батчаPj при j ≠ i, — которые по отношению к Qi случайны:

batch:    (Q1, P1)   (Q2, P2)   (Q3, P3)   (Q4, P4)

for Q1:   positive = P1,   negatives = {P2, P3, P4}
for Q2:   positive = P2,   negatives = {P1, P3, P4}
for Q3:   positive = P3,   negatives = {P1, P2, P4}
for Q4:   positive = P4,   negatives = {P1, P2, P3}

В чём хитрость: прямой проход по P1PN вы бы всё равно выполнили, чтобы посчитать каждое Qi · Pi. Эмбеддинги всех P уже в памяти. Оценить Qi против Pj при j ≠ i — это всего одно дополнительное скалярное произведение, без ещё одного прохода через энкодер. Батч из 256 даёт 255 негативов на запрос «бесплатно» — эквивалент k = 255 вместо типичных для word2vec k = 5–20.

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

Функция потерь

Функция потерь. Потери на пару — это сумма k+1 слагаемых log-сигмоиды (одно для позитива, по одному на каждый негатив) вместо −log P(target | center) полного softmax:

loss = − log σ(v_c · v'_t)  −  Σ log σ(−v_c · v'_n)
       ─────────────────       ───────────────────────
       true (positive) pair     k sampled negatives

Здесь v_c — входной эмбеддинг центрального слова, v'_t — выходной эмбеддинг настоящей цели, v'_n — выходной эмбеддинг засэмплированного негативного слова, а σсигмоида. Первое слагаемое толкает скалярное произведение настоящей пары вверх (к σ(·) = 1); второе толкает скалярное произведение каждого негатива вниз (к σ(·) = 0).

В негативном слагаемом стоит σ(−v_c · v'_n)минус скалярное произведение, — и это работает благодаря тождеству σ(−x) = 1 − σ(x). Так что −log σ(−v_c · v'_n) — это просто −log(1 − σ(v_c · v'_n)): стандартная половина кросс-энтропии «неверный класс», применённая в направлении «это не настоящая пара». Каждое слагаемое функции потерь — это бинарная кросс-энтропия (BCE), применённая к одной паре (center, w): метка 1 для позитива, метка 0 для каждого негатива. Суммарные потери — это k+1 сложенных BCE.

Как ведёт себя −log σ(z) при изменении σ? Когда σ → 1, слагаемое стремится к 0 — верное предсказание, потерь нет. Когда σ → 0, слагаемое взрывается к бесконечности — неверное предсказание, большой штраф. Для негативов всё зеркально: −log(1 − σ) → 0 при σ → 0 (верно) и → ∞ при σ → 1 (модель ошибочно считает случайную пару настоящей). Именно эта асимметричная кривая делает функцию потерь саморегулирующейся: почти верные предсказания почти не двигают потери, очень неверные — доминируют в них. Дальше градиентный спуск автоматически фокусирует внимание на худших предсказаниях.

Каждое оценённое слово вносит одно слагаемое — −log σ для позитива, −log(1 − σ) для каждого негатива:

словорольσслагаемоезначение
onпозитив0.60−log(0.60)0.51
catнегатив0.45−log(1 − 0.45)0.60
matнегатив0.56−log(1 − 0.56)0.83

Итого: L ≈ 1.95. Больше всех вносит mat — его σ (0.56) дальше всего от того, где должен быть негатив (0).

И эта сумма не просто удобна — это совместный логарифм правдоподобия k+1 независимых бинарных решений. Поскольку каждая пара (center, w) оценивается независимо, вероятность того, что все k+1 решений верны, есть произведение их индивидуальных вероятностей:

P(all right)  =  P(positive right) × P(neg₁ right) × … × P(neg_k right)

−log превращает произведение в сумму:

−log P(all right)  =  −log P(positive)  +  −log P(neg₁)  +  …  +  −log P(neg_k)

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

Если отступить на шаг: и softmax, и негативное сэмплирование занимаются максимизацией правдоподобия — они различаются только тем, какое семейство вероятностей моделируют.

softmaxнегативное сэмплирование
что моделируетсяP(target | center) по всему словарюP(настоящая пара | center, w) для каждого w
тип распределенияодно мультиномиальное по V классамk+1 независимых бернуллиевских
выходыV чисел с суммой 1k+1 чисел из (0,1), без ограничения на сумму
потериодна V-классовая кросс-энтропиясумма k+1 бинарных кросс-энтропий

Каждая σ в строке выше — законная вероятность, оценка модели того, что эта конкретная пара (c, w) настоящая, а суммарные потери — подлинное совместное логарифмическое правдоподобие. Негативное сэмплирование полностью вероятностно — просто локально (одна бернуллиевская величина на пару), а не глобально (одно нормированное распределение по словарю).

Отдаёте вы откалиброванное P(target | center), сумма которого по V равна 1, — глобальный, нормированный взгляд. Сохраняете — машинерию максимального правдоподобия: каждая σ есть настоящая вероятность, а градиент есть настоящий ML-градиент. Миколов с соавторами прямо проговаривали этот размен в абзаце о более глубокой сути выше: им нужны были только хорошие векторы, а не откалиброванная вероятностная модель. Функция потерь счастлива, когда σ позитива → 1, каждая σ негатива → 0, а сумма → 0, — и каждый градиентный шаг толкает именно туда.

Градиент

Обратное распространение затрагивает только то, что читал прямой проход. Обновляются k+1 оценённых строк E' — строка цели притягивается к v_c, строка каждого негатива отталкивается, — а остальные V − (k+1) строк, которые никто не читал, вообще не получают градиента. На входной стороне обновляется единственная строка E[center], ровно как в полном softmax.

Чтобы минимизировать L, нам нужен её градиент по каждому параметру, участвовавшему в прямом проходе: v_c (строка центра в E), v'_t (строка цели в E') и каждый v'_n (по строке на негатив). С одним фактом из анализа —

d/dz [ −log σ(z) ] = σ(z) − 1

— цепное правило даёт все три:

∂L / ∂v'_t = (σ_t − 1) · v_c                       ← выходная строка цели
∂L / ∂v'_n = σ_n · v_c                             ← выходная строка каждого негатива
∂L / ∂v_c  = (σ_t − 1) · v'_t  +  Σ_n σ_n · v'_n   ← входная строка центра

где σ_t = σ(v_c · v'_t) и σ_n = σ(v_c · v'_n) — ровно те числа, что в разделе ### Функция потерь выше.

Обратите внимание на симметрию: любой градиент по выходной строке (∂L/∂v'_t, ∂L/∂v'_n) — это скаляр, умноженный на v_c, а градиент центра — взвешенная сумма выходных строк, против которых он оценивался. Это прямое следствие симметричности скалярного произведения по своим аргументам: дифференцирование любого f(v_c · v'_w) по v'_w всегда даёт нечто, пропорциональное v_c, и наоборот.

Отсюда сразу же следуют три наблюдения:

  • Градиент позитива направлен вдоль v_c, с масштабом σ_t − 1 (отрицательное число — σ_t меньше 1). Когда мы вычитаем градиент, v'_t тянется в направлении v_c — к v_c.
  • Градиент каждого негатива тоже направлен вдоль v_c, с масштабом σ_n (положительным). Его вычитание толкает каждый v'_n в направлении −v_c — прочь от v_c.
  • Градиент центра объединяет их все — одно притяжение от цели, по одному отталкиванию на негатив, — каждое со своим весом по тому, насколько сейчас ошибается его сигмоида.

Подставляя игрушечные значения сверху (σ_on = 0.60, σ_cat = 0.45, σ_mat = 0.56, с таблицами эмбеддингов из виджета оценивания):

∂L/∂v_c = (0.60 − 1) · v'_on  +  0.45 · v'_cat  +  0.56 · v'_mat
        ≈ [ −0.04,  0.53,  0.07 ]

Отрицательный вклад позитива (σ_on − 1 = −0.40) и положительные вклады негативов дают то суммарное направление, вдоль которого мы дальше шагнём центром.

Ещё одно изящное свойство: тот же самый скаляр (1 − σ_t) появляется и в обновлении цели (v'_t движется к v_c на (1 − σ_t)·v_c), и в притяжении центра (v_c движется к v'_t на (1 − σ_t)·v'_t). Оба вектора движутся навстречу друг другу ровно на одну и ту же величину — это симметрия коадаптации у функции потерь на скалярном произведении. То же верно и для каждого негатива, только с σ_n. Следующий раздел про обновление это формализует.

Обновление

Градиентный спуск со скоростью обучения η:

v'_t ← v'_t + η · (1 − σ_t) · v_c                    ← шаг к v_c
v'_n ← v'_n − η · σ_n · v_c                          ← шаг прочь от v_c
v_c  ← v_c  + η · (1 − σ_t) · v'_t  −  η · Σ_n σ_n · v'_n
                                                      ← к v'_t, прочь от каждого v'_n

Обратите внимание на размеры шагов:

  • v'_t смещается на η(1 − σ_t) — максимум, когда σ_t мала (позитив не даётся).
  • v'_n смещается на η · σ_n — максимум, когда σ_n велика (негатив ложно выглядит настоящим).

Так что в нашем состоянии mat (σ = 0.56) толкают сильнее, чем cat (σ = 0.45); и чем сильнее ошибка на паре, тем больше поправка. Эта саморегуляция — маленькие поправки, когда модель уже права, большие, когда нет, — и заставляет потери монотонно снижаться по ходу обучения.

Подстановка градиента сверху при η = 0.1 сдвигает центр:

v_c     ≈ [ 0.33, −0.27,  0.84 ]
v_c_new = v_c − η · ∂L/∂v_c  ≈ [ 0.33, −0.32,  0.83 ]

Шаг маленький, но в том направлении, которого требует функция потерь. v'_on, v'_cat и v'_mat получают в тот же момент свои собственные обновления по формулам выше; мы сосредоточились на v_c, чтобы не растягивать разбор.

Проверяем шаг

А обновление действительно помогло? Пересчитаем три скалярных произведения с новым v_c (для наглядности используя те же v'_w — на практике они обновляются одновременно, что усиливает эффект):

словодопосленаправление
v_c · v'_on0.390.41вверх — позитив стал согласованнее ✓
v_c · v'_cat−0.19−0.22вниз — негатив оттолкнут ✓
v_c · v'_mat0.260.25вниз — негатив оттолкнут ✓

Каждая σ движется в нужную сторону, а потери на паре падают с 1.95 до ~1.92. Одна пара, один крошечный шаг — помноженные на миллиарды пар и множество эпох, именно они вытачивают ту геометрию эмбеддингов, где соответствующие друг другу вещи оказываются рядом, а случайные — далеко.

Сравните с шестистрочным циклом на NumPy выше: те три строки обновления — это ровно эти три обновления. Ep[t] -= lr * (s_t - 1) * v_c — шаг v'_t, Ep[negs] -= lr * s_n[:, None] * v_c — шаг негативов, а E[c] -= lr * ((s_t - 1) * v_t + s_n @ v_n) — шаг v_c.

Почему отказ от softmax не бьёт по качеству

Переход от «классификации на V классов» к «бинарной классификации со случайными негативами» выглядит так, будто должен терять информацию: мы больше не максимизируем P(настоящая цель | центр) напрямую. Так почему же получаются столь же хорошие эмбеддинги?

Почему это работает: градиентный сигнал по существу тот же. Градиент полного softmax тянет v_c к v'_t и отталкивает его от выходного эмбеддинга каждого другого слова, взвешивая по предсказанной вероятности этого слова. Негативное сэмплирование делает то же самое, только на засэмплированном подмножестве: притянуть к v'_t, оттолкнуть от эмбеддингов k случайно выбранных слов. По множеству примеров ожидаемый градиент совпадает с градиентом полного softmax с точностью до масштаба. Модель сходится к похожей геометрии; она просто приходит туда, тратя на шаг небольшую константную работу вместо работы, растущей вместе со словарём.

one SGD step on a (center, context) pair · simplified to 2D
v_sat (center)v_cat (true)v_bananav_zebrav_rocket
σ(v_sat · v_w)
cat0.576→ 1
banana0.582→ 0
zebra0.511→ 0
rocket0.516→ 0
loss = 2.866
steps taken: 0

Одна пара, один шаг. Входной вектор центра v_sat (зелёный) притягивается к настоящей цели v_cat (синий) — их скалярное произведение растёт, так что σ(v_sat · v_cat) ползёт к 1 — и прочь от каждого негатива v_banana, v_zebra, v_rocket (красные), так что каждая σ(v_sat · v_neg) падает к 0. Нажимайте step снова и снова и смотрите, как геометрия сама себя раскладывает. Настоящий word2vec выполняет то же обновление в нескольких сотнях измерений, с 5–20 негативами, для каждой из миллиардов пар.

Прогоните тот же цикл на целом словаре — много пар, много обновлений — и таблица эмбеддингов сама рассортируется в кластеры по смыслу:

step-by-step skip-gram training · 21-word vocab, 2-D embeddings, k=5 negatives
thekingsatinpalacequeenruledworkedplayedgardenamanchairwomanboyrangirlonmatcatdog
step
0
epoch ≈ 0 · 532 pairs/epoch
current pair
legend
royalty
adult
youth
animal
verb
place
function

Настоящий skip-gram с негативным сэмплированием, запущенный вживую в браузере на крошечном синтетическом корпусе. Каждый шаг выбирает одну пару (center, context), сэмплирует 5 случайных негативов и применяет одно обновление SGD: v_center притягивается к v_context и отталкивается от каждого негатива. На шаге 0 векторы случайны — слова одного кластера стоят не ближе, чем случайные пары. Нажмите play или +200 и смотрите, как они рассортируются: слова про королевскую семью съезжаются вместе, за ними взрослые, юные, животные, глаголы. Никто не говорил модели, что эти группы существуют; они выпадают сами, потому что слова, делящие контексты в корпусе, в итоге получают строки, которым приходится делить предсказательную геометрию. Векторы насильно сделаны двумерными, чтобы их можно было нарисовать, — сам алгоритм при 300 измерениях идентичен.

Ни одной из моделей никто не говорил, что эти группы существуют; они выпадают сами, потому что слова, делящие контексты в корпусе, в итоге получают строки, которым приходится делить предсказательную геометрию. Векторы насильно сделаны двумерными, чтобы их можно было нарисовать, — при d=300 визуализировать нельзя, но алгоритмы идентичны.

На NumPy внутренний цикл занимает шесть строк:

for c, t in pairs:
    negs = rng.choice(V, size=k, p=neg_dist)
    v_c, v_t, v_n = E[c], Ep[t], Ep[negs]
    s_t = sigmoid(v_c @ v_t)        # want → 1
    s_n = sigmoid(v_c @ v_n.T)       # want → 0 (each)
    Ep[t]    -= lr * (s_t - 1) * v_c           # pull v_t toward v_c
    Ep[negs] -= lr * s_n[:, None]  * v_c       # push v_n's away from v_c
    E[c]     -= lr * ((s_t - 1) * v_t + s_n @ v_n)  # both, into v_c

Вот и весь шаг обучения. Каждая итерация — это один кадр виджетов выше; прогон по миллиардам пар и есть то, что порождает настоящую таблицу эмбеддингов word2vec.

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

Миколов с соавторами (2013) показали, что получающиеся векторы отстают от версии с полным softmax на пару процентов на стандартных бенчмарках аналогий. Леви и Голдберг (2014) позже доказали, что skip-gram с негативным сэмплированием неявно факторизует сдвинутую матрицу PMI (поточечной взаимной информации) — ту самую матрицу, которую более старые методы «сначала посчитай, потом факторизуй» вроде SVD пытались разложить явно. Приём оказался не хаком, а другим путём к тому же математическому пункту назначения.

Что и когда использовать

Негативное сэмплирование и полный softmax — инструменты для разных задач. За softmax остаются два реальных преимущества, которые не важны для целей word2vec, но важны в других местах:

  • Откалиброванные вероятности — softmax даёт настоящую условную вероятность P(target | center), сумма которой по словарю равна 1. k+1 независимых бернуллиевских величин негативного сэмплирования не образуют согласованного распределения; каждую можно прочитать как «настоящая ли это пара?», но не как «какое место занимает это слово среди всех V альтернатив?».
  • Более плотный градиент на шаг — градиент softmax поднимает цель против каждой альтернативы одновременно. Негативное сэмплирование противопоставляет её лишь k случайным примерам за шаг, поэтому пошаговые обновления шумнее: в среднем по множеству примеров результат тот же, но каждый отдельный шаг — более слабый сигнал.

Так что если словарь достаточно мал, чтобы V-классовый softmax был по карману, softmax выигрывает по точности, и платите вы за это немного. Именно поэтому большинство современных классификаторов остаются с ним:

  • Маскированное языковое моделирование в BERT использует полный softmax по своему словарю WordPiece на ~30 000 единиц.
  • Предсказание следующего токена в стиле GPT использует полный softmax по своему BPE-словарю на ~50 000–128 000 единиц.
  • Обычная классификация изображений (от 10 до ~21 000 классов) использует полный softmax.

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

Уберите словарный уровень — и негативное сэмплирование обобщается в одну из важнейших парадигм обучения в современном ML: контрастное обучение. Даны пары вещей, которые сочетаются, и пары, которые нет; выучите эмбеддинги так, чтобы сочетающиеся пары оказывались близко (высокое скалярное произведение / косинусная близость), а несочетающиеся — далеко. «Вещами» может быть что угодно встраиваемое: слова, изображения, предложения, пассажи, аудиофрагменты, узлы графа, пары «пользователь — товар». Форма функции потерь та же — сводить позитивы, расталкивать негативы, — но данные и энкодеры меняются.

Метод лежит в основе большой доли современных процедур обучения. DPR (Dense Passage Retrieval), движок RAG, обучается на позитивах (запрос, релевантный_пассаж) плюс засэмплированных негативах (запрос, нерелевантный_пассаж) — той же формы, что (c, t) против (c, случайное) у word2vec. Модели вознаграждения в RLHF обучаются на парах (предпочтённый_ответ, отвергнутый_ответ) — структурный аналог (позитив, негатив), тот же контрастный вкус. CLIP сводит вместе эмбеддинги соответствующих друг другу пар (изображение, подпись) и расталкивает несоответствующие комбинации внутри батча — выравнивание изображения и текста как один гигантский внутрибатчевый контрастный лосс.

CLIP — идея word2vec, масштабированная до предела

Контрастное обучение применимо ко всему встраиваемому, а не только к словам, и нагляднее всего это показывает CLIP. Word2vec обучал 300-мерные векторы слов на совместной встречаемости в предложениях; CLIP обучал совместные эмбеддинги изображений и текста на 400 миллионах пар (изображение, подпись), собранных из интернета, — используя ту же самую функцию потерь «сводить парное, расталкивать случайное», только с двумя энкодерами вместо одной таблицы эмбеддингов и с внутрибатчевыми негативами вместо розыгрышей по униграммной частоте.

Чем CLIP заслуживает внимания в контексте этой статьи:

  • Он подтверждает центральное утверждение: метод обобщается. Будь негативное сэмплирование специфично для языкового моделирования, CLIP не смог бы работать. То, что он работает, а получающееся пространство эмбеддингов достаточно богато, чтобы поддерживать zero-shot-классификацию изображений (об этом ниже), — прямое свидетельство того, что геометрия притяжения-отталкивания делает настоящую смысловую работу, а не просто запоминает частоты слов.
  • Это архитектурный шаблон, которому следует большинство современных мультимодальных систем. DALL-E использует CLIP. Stable Diffusion использует текстовый энкодер CLIP. Практически у каждой современной системы, выравнивающей изображение и текст, где-то внутри есть контрастный костяк в стиле CLIP. Так что понимать негативное сэмплирование и внутрибатчевые негативы — значит понимать, как всё это обучалось.

Два энкодера — vision transformer для изображений и трансформер для текста — выдают эмбеддинги в общее 512-мерное пространство. Обучающие данные: 400 миллионов пар (изображение, подпись), собранных из интернета.

Для каждого обучающего батча из N пар (I_1, T_1), ..., (I_N, T_N):

  • Закодировать каждое изображение в эмбеддинг; закодировать каждую подпись в эмбеддинг.
  • Вычислить матрицу сходства N × N: S[i][j] = I_embed[i] · T_embed[j] — эмбеддинг каждого изображения, скалярно умноженный на эмбеддинг каждой подписи.
  • Диагональные элементы S[i][i]соответствующие пары (позитивы); всё вне диагонали — несоответствующие (внутрибатчевые негативы, бесплатно поставленные остальной частью батча).
  • Потери: softmax-кросс-энтропия по каждой строке (изображение выбирает свою подпись из N кандидатов) плюс softmax-кросс-энтропия по каждому столбцу (подпись выбирает своё изображение из N кандидатов), сложенные.

При размере батча CLIP в 32K у каждого изображения бесплатно 32K − 1 негативов — все остальные подписи в батче. Сравните с k = 5–20 у word2vec. Внутрибатчевая уловка из начала статьи на таком масштабе становится всей игрой.

Что вы получаете: пространство эмбеддингов, где семантически связанные изображения и тексты оказываются рядом, а несвязанные — далеко. Поэтому CLIP умеет zero-shot-классификацию изображений: вычислите текстовые эмбеддинги для названий классов («фотография собаки», «фотография кошки» и т. д.), а затем классифицируйте изображение по тому, к какому эмбеддингу класса оно ближе. Геометрия, вырезанная контрастной функцией потерь, уже кодирует смысл в обеих модальностях; размеченный классификатор не нужен.

Тот же метод, что ввёл word2vec. Другие данные, другие энкодеры, батчи интернет-масштаба — но кости те же: притягивать парное, расталкивать случайное.

Одну тонкость статья обошла стороной: насколько далеко нужно расталкивать негативы? Каждое негативное слагаемое здесь гонит σ(v_c · v'_n) к 0, что продолжает тянуть скалярное произведение вниз — к косинусу −1, полной противоположности. Но как только классов больше двух, такая цель геометрически недостижима: k единичных векторов не могут все указывать в противоположные друг другу стороны. Лучшее, что они могут, — разойтись в правильный симплекс, где каждая пара стоит под косинусом −1/(k−1), а это с ростом числа классов стремится к ортогональности (cos ≈ 0). Работа Тахи Бухсина Untangling the Moons прослеживает это напряжение по всей контрастной линии — pair-contrastive, triplet, InfoNCE/NT-Xent, SupCon, CLIP, SigLIP — и утверждает, что функции потерь, толкающие к −1, целятся дальше, чем позволяет геометрия, впустую тратя усилия оптимизации и отчасти объясняя, почему CLIP нужны такие огромные батчи, чтобы приблизиться к правильной конфигурации. Это геометрический двойник вероятностной истории, рассказанной здесь, с интерактивными визуализациями «двух лун» для каждой функции потерь; прочитать стоит.

Чистый способ прочитать современный ландшафт: обучение на основе softmax (LLM, BERT, классификаторы изображений) — для предсказания правильного токена из фиксированного небольшого набора; контрастное обучение в стиле негативного сэмплирования — для обучения эмбеддингов двух вещей так, чтобы соответствующие пары оказывались близко, а случайные — далеко. Современный ML использует оба, для разных задач. Word2vec научил область делать второе эффективно, и этот урок состарился крайне хорошо.