Перейти к содержимому
Ступень 44.3 Эмбеддинги

Обучаем word2vec сами: skip-gram и negative sampling

Почему полный softmax по словарю неподъёмен, как негативное сэмплирование превращает skip-gram в логистическую регрессию, градиенты руками и разреженный шаг SGD на NumPy, обучение на маленьком русском корпусе — и как из той же идеи выросли эмбеддинги предложений для RAG и CLIP.

УРОКstage-4/4-3-embeddings/02-train-word2vec.mdx1 ч 50 минУрок 2 из 2
После урока вы сможете
  • Объяснить, почему полный softmax по словарю дорог, и посчитать выигрыш негативного сэмплирования
  • Вывести градиенты loss skip-gram с негативным сэмплированием и реализовать разреженный шаг SGD на NumPy
  • Описать конвейер данных word2vec — словарь, прореживание частых слов, распределение шума — и обучить векторы на маленьком корпусе
  • Связать негативное сэмплирование с контрастивным обучением эмбеддингов предложений (RAG) и CLIP

В прошлом уроке мы обучили skip-gramskip-gramEN skip-gramВариант word2vec — по центральному слову предсказываются слова контекста. В старых китайских текстах — «модель пропуска слов». с негативным сэмплированиемнегативное сэмплированиеEN negative samplingВместо softmax по всему словарю модель учится отличать настоящую пару (слово, контекст) от k случайных «шумовых» пар. на PyTorch. Формулу loss мы записали, а градиенты за нас посчитал автоград. Оригинальный word2vec 2013 года — это около 700 строк на C без всякого автограда: Миколов вывел градиенты руками, и обучение на миллиардах слов шло на обычных процессорах. Сегодня повторим его путь на NumPy. Разберёмся, почему полный softmax неподъёмен, выведем градиенты, напишем разреженный шаг SGD и обучим векторы на маленьком русском корпусе прямо в браузере.

Есть и вторая причина. Негативное сэмплирование — первый широко известный пример контрастивного обучения: притянуть векторы настоящей пары и оттолкнуть случайные. На этой идее стоят эмбеддинг-модели для поиска в RAG (урок 8.2.1) и CLIP, который связывает картинки с текстом (урок 9.1.1). В конце урока мы пройдём этот мостик.

Напомним задачу. У каждого слова два вектора: vwv_w — строка таблицы WinW_{in}, когда слово в центре, и uwu_w — строка WoutW_{out}, когда оно в контексте. Вероятность контекста oo при центре cc:

P(o∣c)=exp⁡(uo⊤vc)∑w=1Vexp⁡(uw⊤vc),L=−log⁡P(o∣c).P(o \mid c) = \frac{\exp(u_o^\top v_c)}{\sum_{w=1}^{V} \exp(u_w^\top v_c)}, \qquad \mathcal{L} = -\log P(o \mid c).

Это softmax с логитами zw=uw⊤vcz_w = u_w^\top v_c. Его градиент вы знаете из урока 3.1.2: ∂L/∂z=p−y\partial \mathcal{L} / \partial z = p - y, где pp — вероятности, а yy — one-hot правильного ответа. Дальше цепное правило через zw=uw⊤vcz_w = u_w^\top v_c:

∂L∂vc=∑w=1Vpw uw−uo,∂L∂uw=(pw−yw) vcдля каждого из V слов.\frac{\partial \mathcal{L}}{\partial v_c} = \sum_{w=1}^{V} p_w\, u_w - u_o, \qquad \frac{\partial \mathcal{L}}{\partial u_w} = (p_w - y_w)\, v_c \quad \text{для каждого из } V \text{ слов}.

Первая формула читается красиво: градиент — «ожидаемый вектор контекста по мнению модели» минус «настоящий». Шаг спуска сдвигает vcv_c к настоящему контексту и от среднего, которое модель ожидала.

Пример. Словарь из трёх слов, vc=(1,0)v_c = (1, 0), u1=(2,0)u_1 = (2, 0), u2=(0,0)u_2 = (0, 0), u3=(0,1)u_3 = (0, 1), настоящий контекст — слово 1. Логиты z=(2,0,0)z = (2, 0, 0), softmax p≈(0.787,0.107,0.107)p \approx (0.787, 0.107, 0.107) — как в примере из 4.3.1. Тогда p−y=(−0.213,0.107,0.107)p - y = (-0.213, 0.107, 0.107) и ∂L/∂vc=0.787⋅(2,0)+0.107⋅(0,0)+0.107⋅(0,1)−(2,0)≈(−0.426,0.107)\partial \mathcal{L}/\partial v_c = 0.787 \cdot (2, 0) + 0.107 \cdot (0, 0) + 0.107 \cdot (0, 1) - (2, 0) \approx (-0.426, 0.107). Важно другое: градиент получили все три вектора uwu_w, а не только участники пары.

Теперь масштаб. Словарь V=100 000V = 100\,000, векторы d=300d = 300. Логиты — VdVd умножений, градиенты всех uwu_w — ещё VdVd, градиент vcv_c — ещё VdVd. Итого около 3Vd=9⋅1073Vd = 9 \cdot 10^7 умножений на одну пару, а пар в корпусе из миллиарда слов — миллиарды (при окне ±5 у слова до 10 соседей, то есть около десяти миллиардов пар). На процессоре 2013 года со скоростью порядка 101110^{11} операций в секунду это около ста дней чистой арифметики (грубая оценка). С негативным сэмплированием на пару нужно 3(k+1)d=54003(k + 1)d = 5400 умножений при k=5k = 5, то есть в V/(k+1)≈17 000V/(k+1) \approx 17\,000 раз меньше: минуты вместо месяцев.

Другие способы обойти softmax: иерархический softmax и NCE

иерархический softmaxиерархический softmaxEN hierarchical softmaxСлова — листья двоичного дерева (в word2vec — дерева Хаффмана). Вероятность слова — произведение log₂V бинарных решений на пути от корня, вместо суммы по всему словарю. раскладывает слова по листьям двоичного дерева. Вероятность слова — произведение вероятностей «налево / направо» на пути от корня, и каждое решение — логистическая регрессия со своим вектором узла. Путь длины log⁡2V\log_2 V: для V=100 000V = 100\,000 это 17 решений вместо суммы по 100 тысячам слов. В word2vec дерево строят по алгоритму Хаффмана: частые слова получают короткие пути, и обучение ускоряется ещё сильнее.

шумо-контрастная оценкашумо-контрастная оценка (NCE)EN noise-contrastive estimation (NCE)Обучение модели отличать настоящие данные от выборки из известного шумового распределения (Gutmann, Hyvärinen, 2010). Негативное сэмплирование word2vec — её упрощение. (Гутман и Хювяринен, 2010) учит модель отличать данные от выборки из известного шумового распределения. Если делать это аккуратно, можно восстановить настоящие вероятности. Негативное сэмплирование — упрощённая NCE: Миколов выкинул поправку на вероятность шума, потому что нужны были хорошие векторы, а не точные вероятности. Поэтому σ(uo⊤vc)\sigma(u_o^\top v_c) в SGNS — не вероятность P(o∣c)P(o \mid c) из softmax, а просто оценка «пара настоящая».

В d2l-zh оба способа разобраны в разделе 14.2 «Приближённое обучение».

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

Заголовок раздела «Негативное сэмплирование как логистическая регрессия»

Вместо вопроса «какое из VV слов — контекст?» модель отвечает на k+1k + 1 вопросов «да/нет». Для настоящей пары (c,o)(c, o) правильный ответ «да» (метка t=1t = 1), для каждого из kk шумовых слов nin_i — «нет» (t=0t = 0):

L=−log⁡σ(uo⊤vc)−∑i=1klog⁡σ(−uni⊤vc).\mathcal{L} = -\log \sigma(u_o^\top v_c) - \sum_{i=1}^{k} \log \sigma(-u_{n_i}^\top v_c).

Каждое слагаемое — это loss логистической регрессиилогистическая регрессияEN logistic regressionНесмотря на название, это метод классификации. У Чжоу Чжихуа («арбузная книга») — «регрессия логарифма шансов», у Ли Хана («Методы статистического обучения») — фонетическая запись английского слова logistic. из урока 2.2.2. Признаки примера — вектор vcv_c, веса — вектор uu. Отличие одно: обучаются и признаки, и веса, ведь обе таблицы — параметры модели.

Обозначим s=u⊤vs = u^\top v — скалярное произведение в одном слагаемом. Сначала производные по ss. Сигмоида σ(s)=1/(1+e−s)\sigma(s) = 1/(1 + e^{-s}) обладает свойством σ′(s)=σ(s)(1−σ(s))\sigma'(s) = \sigma(s)(1 - \sigma(s)). Отсюда:

dds[−log⁡σ(s)]=−σ(s)(1−σ(s))σ(s)=σ(s)−1,dds[−log⁡σ(−s)]=σ(s).\frac{d}{ds}\big[-\log \sigma(s)\big] = -\frac{\sigma(s)(1 - \sigma(s))}{\sigma(s)} = \sigma(s) - 1, \qquad \frac{d}{ds}\big[-\log \sigma(-s)\big] = \sigma(s).

Вторую формулу получите сами из первой: замените ss на −s-s и вспомните, что σ(−s)=1−σ(s)\sigma(-s) = 1 - \sigma(s). Обе формулы — это одна запись:

∂ℓ∂s=σ(s)−t,t=1 у настоящей пары,t=0 у негатива.\frac{\partial \ell}{\partial s} = \sigma(s) - t, \qquad t = 1 \text{ у настоящей пары}, \quad t = 0 \text{ у негатива}.

«Предсказание минус метка» — тот же вид, что у логистической регрессии и у softmax (p−yp - y). Если модель уверенно права (σ(s)≈t\sigma(s) \approx t), градиент почти ноль, если уверенно ошибается — почти ±1\pm 1.

Осталось цепное правило через s=u⊤vs = u^\top v: ∂s/∂v=u\partial s/\partial v = u и ∂s/∂u=v\partial s/\partial u = v. Обозначим go=σ(so)−1g_o = \sigma(s_o) - 1 и gi=σ(sni)g_i = \sigma(s_{n_i}):

∂L∂vc=go uo+∑i=1kgi uni,∂L∂uo=go vc,∂L∂uni=gi vc.\frac{\partial \mathcal{L}}{\partial v_c} = g_o\, u_o + \sum_{i=1}^{k} g_i\, u_{n_i}, \qquad \frac{\partial \mathcal{L}}{\partial u_o} = g_o\, v_c, \qquad \frac{\partial \mathcal{L}}{\partial u_{n_i}} = g_i\, v_c.

Словами: вектор центра тянется к настоящему контексту (у gog_o знак минус, шаг спуска идёт против градиента) и отталкивается от негативов. Вектор контекста тянется к центру, векторы негативов от него отталкиваются.

Пример. d=2d = 2, один негатив: vc=(1,0.5)v_c = (1, 0.5), uo=(0.5,1)u_o = (0.5, 1), un=(−1,0.5)u_n = (-1, 0.5).

  • so=0.5+0.5=1s_o = 0.5 + 0.5 = 1, σ(1)≈0.731\sigma(1) \approx 0.731, go≈−0.269g_o \approx -0.269.
  • sn=−1+0.25=−0.75s_n = -1 + 0.25 = -0.75, σ(−0.75)≈0.321\sigma(-0.75) \approx 0.321, gn≈0.321g_n \approx 0.321.
  • L=−log⁡0.731−log⁡(1−0.321)≈0.313+0.387=0.700\mathcal{L} = -\log 0.731 - \log(1 - 0.321) \approx 0.313 + 0.387 = 0.700.
  • ∇vc=−0.269⋅(0.5,1)+0.321⋅(−1,0.5)≈(−0.455,−0.109)\nabla v_c = -0.269 \cdot (0.5, 1) + 0.321 \cdot (-1, 0.5) \approx (-0.455, -0.109); ∇uo=−0.269⋅(1,0.5)≈(−0.269,−0.134)\nabla u_o = -0.269 \cdot (1, 0.5) \approx (-0.269, -0.134); ∇un=0.321⋅(1,0.5)≈(0.321,0.160)\nabla u_n = 0.321 \cdot (1, 0.5) \approx (0.321, 0.160).

Один шаг SGD с η=0.5\eta = 0.5 даёт vc≈(1.228,0.554)v_c \approx (1.228, 0.554), uo≈(0.634,1.067)u_o \approx (0.634, 1.067), un≈(−1.160,0.420)u_n \approx (-1.160, 0.420), и loss на этой паре падает с 0.700 до 0.491. Значения loss и градиентов из этого примера проверяет один из тестов задания.

А вот как то же самое написал Миколов (строки 535–537 word2vec.c, ветка skip-gram; f — скалярное произведение):

if (f > MAX_EXP) g = (label - 1) * alpha;
else if (f < -MAX_EXP) g = (label - 0) * alpha;
else g = (label - expTable[(int)((f + MAX_EXP) * (EXP_TABLE_SIZE / MAX_EXP / 2))]) * alpha;

Здесь g = (label − σ(f)) · alpha — наш −(σ(s)−t)-(\sigma(s) - t), сразу умноженный на шаг обучения alpha. Знак обратный: Миколов прибавляет g, то есть поднимается по логарифму правдоподобия, а мы спускаемся по loss. Сигмоида берётся из заранее посчитанной таблицы expTable на 1000 значений: в 2013 году экспонента на каждом шаге казалась дорогой. При ∣f∣>6|f| > 6 сигмоиду считают ровно 0 или 1.

Шаг для одной пары меняет одну строку WinW_{in} и k+1k + 1 строк WoutW_{out}. Все остальные строки не трогаются: из 100 тысяч строк обновляются семь. Поэтому word2vec обучается быстро и хорошо параллелится. word2vec.c запускает несколько потоков, которые пишут в общие таблицы без блокировок. Изредка два потока одновременно правят одну строку, и одно из обновлений частично теряется, но при разреженных обновлениях это почти не вредит.

Мы будем обновлять сразу батч из BB пар — так NumPy работает быстрее. Тут есть ловушка, знакомая по nn.Embedding из 4.3.1 и по ручному backprop в уроке 4.1.4: одно слово может встретиться в батче несколько раз, и его градиенты нужно сложить. Запись W_in[c] -= lr * grad_v при повторах в c молча оставит только одно обновление. Правильно — np.add.at(W_in, c, -lr * grad_v): это scatter-addscatter-add (сложение по индексам)EN scatter-addОперация «разложить значения по строкам массива, складывая совпадающие индексы». Так собирается градиент таблицы эмбеддингов. В NumPy — np.add.at, в PyTorch — index_add_; запись dC[X] += dE в NumPy теряет повторы., «прибавить по индексам с учётом повторов».

import numpy as np
W = np.zeros(3)
idx = np.array([0, 0, 2]) # слово 0 встретилось в батче дважды
W[idx] += 1.0 # обычная индексация: повтор потерян
print(W)
W = np.zeros(3)
np.add.at(W, idx, 1.0) # scatter-add: оба вклада сложились
print(W)
[1. 0. 1.]
[2. 0. 1.]

Шаг обучения в word2vec.c линейно падает от начального значения (по умолчанию 0.025 для skip-gram) почти до нуля к концу обучения: ηt=η0(1−t/T)\eta_t = \eta_0 (1 - t/T). Это простейшее расписание learning rateрасписание learning rateEN learning rate scheduleПравило, по которому learning rate меняется по ходу обучения — разогрев, плато, спад. из урока 1.4.2. В нашем задании будет так же.

Модель простая, и качество во многом решает подготовка данных. Четыре шага:

  1. Словарь. Слова, встретившиеся реже min_count раз (в word2vec.c по умолчанию 5), выбрасывают: по паре вхождений хороший вектор не выучить, а словарь раздувается.
  2. прореживание частых словпрореживание частых словEN subsampling of frequent wordsПеред обучением каждое вхождение слова w выбрасывают с вероятностью 1 − √(t / f(w)), где f(w) — доля слова в корпусе. «и», «в», «на» теряют большую часть вхождений, редкие слова не трогаются.. Слова «и», «в», «на» стоят рядом почти со всем и мало говорят о смысле соседей. Каждое вхождение слова ww выбрасывают с вероятностью Pdrop(w)=1−tf(w),P_{\text{drop}}(w) = 1 - \sqrt{\frac{t}{f(w)}}, где f(w)f(w) — доля слова в корпусе, а tt — порог (в статье 10−510^{-5}). Слово с долей 5% теряет 1−10−5/0.05≈98.6%1 - \sqrt{10^{-5}/0.05} \approx 98.6\% вхождений, слово с долей 0.1% — 90%, а слова с долей меньше tt остаются все. Корпус становится короче и «содержательнее», а окно ±5 после чистки дотягивается до более далёких значимых слов.
  3. Пары с динамическим окном. word2vec.c для каждого центрального слова выбирает размер окна случайно от 1 до window. Ближние соседи попадают в пары чаще дальних, и это неявно придаёт им больший вес.
  4. шумовое распределениешумовое распределениеEN noise distributionРаспределение, из которого берут негативы. В word2vec — частоты слов в степени 3/4: частые слова берутся реже, чем пропорционально частоте, редкие — чаще.. Негативы берут пропорционально частоте в степени 3/43/4. Пример: два слова с частотами 900 и 100. Пропорционально частоте их брали бы в 90% и 10% случаев, а после степени 3/43/4: 9000.75≈164900^{0.75} \approx 164 и 1000.75≈32100^{0.75} \approx 32, то есть 83.9% и 16.1%. Редкие слова попадают в негативы чаще, чем по частоте, но частые всё равно доминируют. Степень 3/43/4 Миколов подобрал экспериментально.
flowchart LR
T["Текст"] --> V["Словарь<br/>min_count"] --> S["Прореживание<br/>частых слов"] --> P["Пары (центр, контекст)<br/>динамическое окно"]
P --> N["+ k негативов<br/>из частоты^(3/4)"] --> G["Шаг SGD<br/>7 строк из V"]
Конвейер word2vec: от текста к разреженным шагам SGD

В задании 114 коротких предложений на пять тем: животные, напитки, города, семья, времена года. Это около 560 слов. Слова специально стоят в одной форме («кошка спит», «кошка ловит»): иначе «кошка», «кошку» и «кошке» стали бы тремя разными словами со своими редкими счётчиками (вспомните проблему морфологии из 4.3.1). Прореживание здесь выключено: корпус слишком мал, и при t=10−5t = 10^{-5} союз «и» исчез бы почти целиком.

Вы реализуете пять функций: устойчивую сигмоиду, извлечение пар, распределение шума, loss с градиентами и шаг SGD со scatter-add. Цикл обучения, словарь и поиск соседей уже написаны: d=20d = 20, окно 2, k=5k = 5, батч 32, 40 эпох, η0=0.05\eta_0 = 0.05 с линейным спадом.

word2vec на NumPy: градиенты SGNS и разреженный шаг SGDnumpy · нужен интернет7 тестов
import numpy as np

# ── Крошечный русский корпус (готово) ─────────────────────────────────────────
# 114 коротких предложений на пять тем: животные, напитки, города, семья, времена года.
CORPUS = """
кошка спит на тёплом диване
кот спит на диване весь день
кошка ловит мышь в подвале
кот ловит мышь на кухне
собака громко лает во дворе
пёс громко лает на почтальона
собака охраняет дом ночью
пёс охраняет дом и двор
кошка пьёт молоко из миски
кот пьёт молоко из блюдца
собака грызёт кость во дворе
пёс грызёт кость у будки
кошка мурлычет у камина
кот мурлычет на коленях у бабушки
собака бежит за мячом в парке
пёс бежит за палкой в парке
кошка и кот играют с клубком
собака и пёс гуляют в парке
мама пьёт горячий чай утром
папа пьёт горячий кофе утром
бабушка пьёт чай с вареньем
дедушка пьёт кофе с молоком
мама наливает чай в чашку
папа наливает кофе в чашку
я пью зелёный чай без сахара
я пью чёрный кофе без сахара
чай и кофе лучше пить горячими
в кафе подают чай кофе и сок
дети пьют сок и молоко
сестра пьёт апельсиновый сок за завтраком
брат пьёт яблочный сок за завтраком
молоко полезно для детей
москва столица россии
пекин столица китая
париж столица франции
токио столица японии
мы летим в москву летом
мы летим в пекин зимой
мы летим в париж весной
мы летим в токио осенью
москва большой и шумный город
пекин большой и древний город
париж красивый и старый город
токио огромный и современный город
в москве много метро и парков
в пекине много метро и парков
в париже много музеев и кафе
в токио много метро и музеев
шанхай большой город в китае
петербург большой город в россии
мама готовит обед на кухне
бабушка готовит пирог на кухне
папа читает газету на диване
дедушка читает книгу на диване
мама звонит бабушке каждый вечер
папа звонит дедушке каждый вечер
бабушка и дедушка живут в деревне
мама и папа живут в москве
сестра учится в школе
брат учится в школе
сестра и брат играют во дворе
мама любит папу и детей
бабушка любит внуков и пироги
дедушка любит рыбалку и внуков
зимой идёт снег и холодно
летом светит солнце и тепло
весной тает снег и поют птицы
осенью идёт дождь и падают листья
зима холодное время года
лето тёплое время года
весна тёплое время года
осень холодное время года
зимой дети катаются на санках
летом дети купаются в реке
весной в парке цветут деревья
осенью в парке жёлтые листья
снег лежит во дворе всю зиму
дождь идёт весь день осенью
солнце светит весь день летом
кошка не любит дождь и снег
собака любит снег и прогулки
бабушка печёт пирог зимой
папа пьёт кофе и читает газету
мама пьёт чай и читает книгу
кошка спит на подоконнике
кот спит у камина
кошка ест рыбу на кухне
кот ест рыбу из миски
собака ест мясо из миски
пёс ест мясо у будки
собака спит у двери
пёс спит у будки
чай пьют из чашки
кофе пьют из чашки
мама пьёт сок за завтраком
папа пьёт молоко за завтраком
горячий чай с лимоном
горячий кофе с сахаром
бабушка заваривает чай на кухне
дедушка варит кофе на кухне
москва и пекин большие столицы
париж и токио большие столицы
туристы летят в париж
туристы летят в токио
туристы летят в москву
туристы летят в пекин
мама и бабушка готовят обед
папа и дедушка читают газету
сестра звонит маме вечером
брат звонит папе вечером
зимой холодно и идёт снег
летом тепло и светит солнце
весной тепло и светит солнце
осенью холодно и идёт дождь
"""
SENTENCES = [line.split() for line in CORPUS.strip().split("\n")]


# ── Ваши функции ──────────────────────────────────────────────────────────────
def sigmoid(x):
    """σ(x) = 1 / (1 + e^(−x)) — поэлементно и без переполнения при больших |x|.

    Подсказка: σ(x) = (1 + tanh(x / 2)) / 2 — та же функция, но tanh не переполняется.
    """
    # TODO
    ...


def make_pairs(ids, window):
    """Пары (центр, контекст) для всех предложений. ids — список предложений, каждое — список номеров слов.

    Для слова на позиции i контекст — слова на позициях j от i − window до i + window (в пределах предложения),
    кроме самого i. Вернуть np.array формы (число пар, 2) с dtype=np.int64.
    """
    # TODO
    ...


def noise_distribution(counts, power=0.75):
    """Вероятности выбора негативов: counts ** power, нормированные так, чтобы сумма была 1."""
    # TODO
    ...


def sgns_loss_and_grads(v, u_pos, u_neg):
    """Loss skip-gram с негативным сэмплированием и его градиенты для батча из B пар.

    v      — (B, d)    векторы центральных слов (строки W_in);
    u_pos  — (B, d)    векторы настоящих контекстов (строки W_out);
    u_neg  — (B, k, d) векторы k негативов для каждой пары (строки W_out).
    loss = Σ по батчу [ −log σ(u_pos·v) − Σ_i log σ(−u_neg_i·v) ]   (сумма, не среднее!)
    Градиенты: g_pos = σ(s_pos) − 1, g_neg = σ(s_neg);
      grad_v = g_pos·u_pos + Σ_i g_neg_i·u_neg_i;  grad_u_pos = g_pos·v;  grad_u_neg_i = g_neg_i·v.
    Вернуть кортеж (loss, grad_v, grad_u_pos, grad_u_neg) — формы градиентов как у v, u_pos, u_neg.
    Подсказка: s_neg = np.einsum("bkd,bd->bk", u_neg, v).
    """
    # TODO
    ...


def sgd_step(W_in, W_out, c, o, negs, lr):
    """Один шаг SGD для батча пар: c (B,) — центры, o (B,) — контексты, negs (B, k) — негативы.

    1) достать строки: v = W_in[c], u_pos = W_out[o], u_neg = W_out[negs];
    2) посчитать градиенты через sgns_loss_and_grads;
    3) обновить таблицы НА МЕСТЕ, вычитая lr · градиент. Одно слово может встретиться в батче несколько раз,
       поэтому используйте np.add.at(W_in, c, -lr * grad_v) — обычное W_in[c] -= … потеряет повторы.
    Вернуть средний loss на пару (loss / B).
    """
    # TODO
    ...


# ── Обучение и проверка (готово) ──────────────────────────────────────────────
def build_vocab(sentences, min_count=2):
    """Словарь из слов, встретившихся не реже min_count раз; предложения → списки номеров."""
    counts = {}
    for s in sentences:
        for w in s:
            counts[w] = counts.get(w, 0) + 1
    vocab = sorted(w for w, n in counts.items() if n >= min_count)
    stoi = {w: i for i, w in enumerate(vocab)}
    ids = [[stoi[w] for w in s if w in stoi] for s in sentences]
    return vocab, stoi, ids, np.array([counts[w] for w in vocab])


def train(sentences, d=20, window=2, k=5, epochs=60, lr=0.05, batch=32, seed=0):
    """Обучение skip-gram с негативным сэмплированием. Возвращает (vocab, stoi, W_in, история loss)."""
    rng = np.random.default_rng(seed)
    vocab, stoi, ids, counts = build_vocab(sentences)
    V = len(vocab)
    W_in = (rng.random((V, d)) - 0.5) / d                  # как в word2vec.c: маленькие случайные числа
    W_out = np.zeros((V, d))                               # ...а векторы контекстов — нули
    pairs = make_pairs(ids, window)
    p_noise = noise_distribution(counts)
    total, step, history = epochs * ((len(pairs) + batch - 1) // batch), 0, []
    for epoch in range(epochs):
        perm = rng.permutation(len(pairs))                 # перемешать пары
        negs_all = rng.choice(V, size=(len(pairs), k), p=p_noise)   # k негативов на каждую пару
        losses = []
        for b in range(0, len(pairs), batch):
            idx = perm[b:b + batch]
            cur_lr = lr * max(1e-4, 1 - step / total)      # lr линейно падает к нулю, как в word2vec.c
            losses.append(sgd_step(W_in, W_out, pairs[idx, 0], pairs[idx, 1], negs_all[b:b + batch], cur_lr))
            step += 1
        history.append(float(np.mean(losses)))
    return vocab, stoi, W_in, history


def most_similar(word, W, vocab, stoi, topn=3):
    """Ближайшие по косинусу слова (само слово исключено)."""
    E = W / np.linalg.norm(W, axis=1, keepdims=True)
    sims = E @ E[stoi[word]]
    best = [i for i in np.argsort(-sims) if i != stoi[word]][:topn]
    return [(vocab[i], round(float(sims[i]), 2)) for i in best]


# ── Запуск для кнопки «Запустить» ──
try:
    vocab, stoi, W_in, history = train(SENTENCES, epochs=40)
    print(f"словарь: {len(vocab)} слов; loss: эпоха 1 — {history[0]:.3f}, последняя — {history[-1]:.3f}")
    for w in ["кошка", "чай", "москва", "мама", "зимой"]:
        print(f"{w:7s} →", most_similar(w, W_in, vocab, stoi))
except Exception as e:  # пока функции не готовы, обучение падает — покажем подсказку вместо ошибки
    print(f"Обучение пока не запускается ({type(e).__name__}: {e}). Доделайте функции выше.")

После решения кнопка «Запустить» выведет примерно такое (точные числа зависят от версии NumPy):

словарь: 112 слов; loss: эпоха 1 — 4.159, последняя — 1.693
кошка → [('кот', 0.95), ('мурлычет', 0.84), ('рыбу', 0.84)]
чай → [('кофе', 0.96), ('папа', 0.88), ('пью', 0.85)]
москва → [('большие', 0.99), ('город', 0.97), ('париж', 0.95)]
мама → [('папа', 0.92), ('горячий', 0.9), ('бабушка', 0.86)]
зимой → [('идёт', 0.82), ('дождь', 0.82), ('осенью', 0.81)]

Первая эпоха начинается ровно с 6ln⁡2≈4.166 \ln 2 \approx 4.16 — так и должно быть при Wout=0W_{out} = 0. «Кошка» нашла «кота», «чай» — «кофе», «мама» — «папу», а «зимой» — «осенью». На 560 словах соседи шумные: рядом с «чаем» оказался «папа», потому что в корпусе «папа пьёт кофе» и «мама пьёт чай» стоят в одинаковых окружениях. Тесты проверяют главное: похожие слова ближе непохожих с заметным запасом.

Попробуйте сами:

  • Поставьте k=1 и k=15. Как меняются стартовый loss ((k+1)ln⁡2(k + 1)\ln 2) и качество соседей?
  • Замените в train случайную инициализацию WinW_{in} нулями. Что покажет loss? Сверьтесь с врезкой выше.
  • Сравните соседей по WinW_{in} и по Win+WoutW_{in} + W_{out} (для этого train придётся вернуть и WoutW_{out}). Сумму двух таблиц использует GloVe, и иногда она даёт соседей почище.

Аналогии вроде «москва − россия + франция ≈ париж» на 560 словах не заработают: чтобы разность векторов стала устойчивым «направлением», нужны тысячи примеров каждого отношения. Миколов проверял векторы на наборе из 19 544 аналогий (8 869 смысловых и 10 675 грамматических), обучая модель на миллиардах слов. Для больших корпусов используют готовую быструю реализацию — библиотеку gensim. В ней те же гиперпараметры и те же две таблицы:

from gensim.models import Word2Vec # pip install gensim
model = Word2Vec(
sentences=SENTENCES, # список предложений, каждое — список слов (корпус из задания)
vector_size=20, # d
window=2,
min_count=2,
sg=1, # 1 = skip-gram, 0 = CBOW
negative=5, # k
sample=0, # прореживание выключено: корпус крошечный
epochs=200, workers=1, seed=0,
)
print([(w, round(s, 2)) for w, s in model.wv.most_similar("кошка", topn=3)])
print(model.wv.vectors.shape, model.syn1neg.shape) # W_in и W_out
[('кот', 0.91), ('диване', 0.69), ('камина', 0.69)]
(112, 20) (112, 20)

Соседи у gensim немного другие: иная инициализация и порядок обхода, а 200 эпох вместо 40 (числа могут отличаться и между версиями библиотеки). model.wv.vectors — это наша WinW_{in}, model.syn1neg — WoutW_{out} (имя взято прямо из word2vec.c).

От слов к предложениям: контрастивное обучение

Заголовок раздела «От слов к предложениям: контрастивное обучение»

Для поиска по документам нужен вектор не слова, а целого текста — эмбеддинг предложенияэмбеддинг предложенияEN sentence embeddingОдин вектор на целое предложение или абзац, такой что близкие по смыслу тексты дают близкие векторы. Простейший вариант — среднее векторов слов; современный — трансформер, обученный контрастивно.. Простейший способ — усреднение векторовусреднение векторов (mean pooling)EN mean poolingВектор текста как среднее векторов его токенов. Дёшево, но теряет порядок слов: «собака укусила почтальона» и «почтальон укусил собаку» из одних и тех же слов дают один вектор.: усреднить векторы слов. С векторами из задания:

def sentence_vector(text):
"""Эмбеддинг предложения как среднее векторов известных слов (mean pooling)."""
ids = [stoi[w] for w in text.split() if w in stoi] # слова не из словаря пропускаем
v = W_in[ids].mean(axis=0)
return v / np.linalg.norm(v)
a = sentence_vector("кошка спит на диване")
for t in ["кот спит у камина", "собака охраняет дом", "мама пьёт горячий чай", "диване на спит кошка"]:
print(f"{t:25s} {a @ sentence_vector(t):.2f}")
кот спит у камина 0.93
собака охраняет дом 0.63
мама пьёт горячий чай 0.46
диване на спит кошка 1.00

Для грубого поиска по теме этого хватает: про кота ближе, про чай дальше. Но последняя строка показывает предел: порядок слов потерян. «Собака укусила почтальона» и «почтальон укусил собаку» из одних слов дадут один вектор. Многозначность тоже никуда не делась. Нужен вектор, который учитывает контекст, то есть трансформер (модуль 4.4 и дальше), и обучение, которое прямо требует «похожие тексты — рядом».

Вспомните, чем был loss word2vec: настоящая пара против kk случайных. Замените «слово и его контекст» на «вопрос и абзац с ответом» — получится обучение эмбеддинг-моделей для RAG. Замените на «картинку и её подпись» — получится CLIP. Это и есть контрастивное обучениеконтрастивное обучениеEN contrastive learningсближаем представления «правильных пар» и раздвигаем «неправильные»:

Положительная параОткуда негативыФункция потерь
word2vec (2013)слово и слово из его окнаслучайные слова из частоты3/4^{3/4}сигмоида на каждую пару
SimCSE (2021)одно предложение, пропущенное через сеть дважды с разным dropoutчужие предложения батчаsoftmax по батчу (InfoNCE)
BGE, E5, Qwen3-Embeddingзапрос и подходящий фрагментфрагменты из батча + трудные негативыInfoNCE
CLIP (2021)картинка и её подписьчужие подписи батчаInfoNCE в обе стороны
SigLIP (2023)картинка и её подписьвсе чужие пары батчасигмоида на каждую пару

Что изменилось со времён word2vec:

  • Негативы из батча (негативы из батчанегативы из батчаEN in-batch negativesВ контрастивном обучении негативами для пары служат чужие примеры того же батча: батч из B пар даёт каждой паре B − 1 негатив бесплатно.) вместо шумового распределения. Батч из BB пар даёт каждой паре B−1B - 1 негатив бесплатно: их векторы всё равно уже посчитаны.
  • Softmax по кандидатам вместо отдельных сигмоид: InfoNCEInfoNCE (контрастивная функция потерь)EN InfoNCE losscross-entropy по матрице сходств, где правильные пары стоят на диагонали — это cross-entropy, где «классы» — кандидаты, а правильный — настоящая пара. Формулу вы увидите в 8.2.1 и 9.1.1. Обратите внимание на последнюю строку таблицы: Google в SigLIP вернулся к сигмоиде на каждую пару, и это снова логистическая регрессия, как у Миколова.
  • Трудные негативытрудные негативыEN hard negativesНегативы, похожие на правильный ответ (фрагмент на ту же тему, но не отвечающий на вопрос). Учат тонким различиям; ими дообучают эмбеддинг-модели для поиска.. Случайный негатив почти всегда очевидно чужой: модель быстро учится его отталкивать, и градиент σ(s)≈0\sigma(s) \approx 0 исчезает. Абзац на ту же тему, но без ответа на вопрос, учит тонким различиям. Такие негативы ищут специально — например, берут самые похожие фрагменты, которые находит предыдущая версия модели.
  • Энкодер вместо таблицы. Вектор текста считает трансформер (урок 4.5.4), поэтому он зависит от порядка слов и контекста. Таблица эмбеддингов осталась только на входе.
Глубже: что на самом деле выучивает SGNS

Омер Леви и Йоав Голдберг (2014) показали, что в оптимуме skip-gram с негативным сэмплированием векторы удовлетворяют

uo⊤vc≈PMI(c,o)−log⁡k,PMI(c,o)=log⁡P(c,o)P(c) P(o).u_o^\top v_c \approx \mathrm{PMI}(c, o) - \log k, \qquad \mathrm{PMI}(c, o) = \log \frac{P(c, o)}{P(c)\,P(o)}.

точечная взаимная информацияточечная взаимная информация (PMI)EN pointwise mutual information (PMI)PMI(w, c) = log P(w, c) / (P(w) P(c)) — во сколько раз чаще слова встречаются вместе, чем если бы были независимы. Skip-gram с негативным сэмплированием неявно приближает PMI − log k. показывает, во сколько раз чаще слова встречаются вместе, чем если бы были независимы. Значит, word2vec неявно раскладывает матрицу «слово × контекст» со сдвинутыми PMI на произведение двух таблиц WinWout⊤W_{in} W_{out}^\top. Это почти то же, что делают счётные методы из 4.3.1 со своей матрицей счётчиков. «Предсказательный» и «счётный» пути сходятся к одному: геометрия векторов отражает статистику совместной встречаемости. Подробный разбор — у Лены Войты (раздел Word2Vec и Related Papers).

Проверь себя6 вопросов
1 Словарь V=50 000V = 50\,000, k=5k = 5 негативов. Во сколько раз (примерно) негативное сэмплирование дешевле полного softmax на одну пару? Ответ — V/(k+1)V/(k+1), округлите до целого.
2 Для негатива скалярное произведение s=un⊤vc=0s = u_n^\top v_c = 0. Чему равна производная ∂ℓ/∂s\partial \ell / \partial s для слагаемого −log⁡σ(−s)-\log\sigma(-s)?
3 Шаг SGD для одной пары (c,o)(c, o) с k=3k = 3 негативами. Какие строки таблиц изменятся?Можно выбрать несколько ответов
4 Почему в word2vec.c таблицу WoutW_{out} инициализируют нулями, а WinW_{in} — маленькими случайными числами, а не обе нулями?
5 Слово занимает 1% корпуса, порог прореживания t=10−4t = 10^{-4}. Какую долю вхождений этого слова выбросят? (до двух знаков)
6 Эмбеддинг предложения как среднее векторов слов (mean pooling). Какой главный недостаток показал пример «диване на спит кошка»?
  • Полный softmax в skip-gram трогает все VV векторов контекста на каждой паре. Негативное сэмплирование сводит задачу к k+1k + 1 логистическим регрессиям и ускоряет обучение примерно в V/(k+1)V/(k+1) раз.
  • Градиенты: ∂ℓ/∂s=σ(s)−t\partial\ell/\partial s = \sigma(s) - t, затем ∇v=∑g u\nabla v = \sum g\,u, ∇u=g v\nabla u = g\,v. Шаг SGD меняет k+2k + 2 строки, а повторы в батче складывают через scatter-add (np.add.at).
  • Конвейер word2vec: словарь с min_count, прореживание частых слов 1−t/f1 - \sqrt{t/f}, динамическое окно, шум из частоты3/4^{3/4}, линейно падающий lr. WoutW_{out} стартует с нулей, и стартовый loss равен (k+1)ln⁡2(k+1)\ln 2.
  • Mean pooling даёт грубый эмбеддинг предложения, но теряет порядок слов. Современные эмбеддинг-модели (SimCSE, BGE, E5) и CLIP — наследники негативного сэмплирования: контрастивный loss, негативы из батча, трудные негативы.

Дальше: модуль 4.4 «Механизм внимания» — как токены обмениваются информацией и почему вектор «ключа» начинает зависеть от контекста — Внимание интуитивно.