Обучаем word2vec сами: skip-gram и negative sampling
Почему полный softmax по словарю неподъёмен, как негативное сэмплирование превращает skip-gram в логистическую регрессию, градиенты руками и разреженный шаг SGD на NumPy, обучение на маленьком русском корпусе — и как из той же идеи выросли эмбеддинги предложений для 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). В конце урока мы пройдём этот мостик.
Цена полного softmax
Заголовок раздела «Цена полного softmax»Напомним задачу. У каждого слова два вектора: — строка таблицы , когда слово в центре, и — строка , когда оно в контексте. Вероятность контекста при центре :
Это softmax с логитами . Его градиент вы знаете из урока 3.1.2: , где — вероятности, а — one-hot правильного ответа. Дальше цепное правило через :
Первая формула читается красиво: градиент — «ожидаемый вектор контекста по мнению модели» минус «настоящий». Шаг спуска сдвигает к настоящему контексту и от среднего, которое модель ожидала.
Пример. Словарь из трёх слов, , , , , настоящий контекст — слово 1. Логиты , softmax — как в примере из 4.3.1. Тогда и . Важно другое: градиент получили все три вектора , а не только участники пары.
Теперь масштаб. Словарь , векторы . Логиты — умножений, градиенты всех — ещё , градиент — ещё . Итого около умножений на одну пару, а пар в корпусе из миллиарда слов — миллиарды (при окне ±5 у слова до 10 соседей, то есть около десяти миллиардов пар). На процессоре 2013 года со скоростью порядка операций в секунду это около ста дней чистой арифметики (грубая оценка). С негативным сэмплированием на пару нужно умножений при , то есть в раз меньше: минуты вместо месяцев.
Другие способы обойти softmax: иерархический softmax и NCE
иерархический softmaxиерархический softmaxEN hierarchical softmaxСлова — листья двоичного дерева (в word2vec — дерева Хаффмана). Вероятность слова — произведение log₂V бинарных решений на пути от корня, вместо суммы по всему словарю. раскладывает слова по листьям двоичного дерева. Вероятность слова — произведение вероятностей «налево / направо» на пути от корня, и каждое решение — логистическая регрессия со своим вектором узла. Путь длины : для это 17 решений вместо суммы по 100 тысячам слов. В word2vec дерево строят по алгоритму Хаффмана: частые слова получают короткие пути, и обучение ускоряется ещё сильнее.
шумо-контрастная оценкашумо-контрастная оценка (NCE)EN noise-contrastive estimation (NCE)Обучение модели отличать настоящие данные от выборки из известного шумового распределения (Gutmann, Hyvärinen, 2010). Негативное сэмплирование word2vec — её упрощение. (Гутман и Хювяринен, 2010) учит модель отличать данные от выборки из известного шумового распределения. Если делать это аккуратно, можно восстановить настоящие вероятности. Негативное сэмплирование — упрощённая NCE: Миколов выкинул поправку на вероятность шума, потому что нужны были хорошие векторы, а не точные вероятности. Поэтому в SGNS — не вероятность из softmax, а просто оценка «пара настоящая».
В d2l-zh оба способа разобраны в разделе 14.2 «Приближённое обучение».
Негативное сэмплирование как логистическая регрессия
Заголовок раздела «Негативное сэмплирование как логистическая регрессия»Вместо вопроса «какое из слов — контекст?» модель отвечает на вопросов «да/нет». Для настоящей пары правильный ответ «да» (метка ), для каждого из шумовых слов — «нет» ():
Каждое слагаемое — это loss логистической регрессиилогистическая регрессияEN logistic regressionНесмотря на название, это метод классификации. У Чжоу Чжихуа («арбузная книга») — «регрессия логарифма шансов», у Ли Хана («Методы статистического обучения») — фонетическая запись английского слова logistic. из урока 2.2.2. Признаки примера — вектор , веса — вектор . Отличие одно: обучаются и признаки, и веса, ведь обе таблицы — параметры модели.
Градиенты руками
Заголовок раздела «Градиенты руками»Обозначим — скалярное произведение в одном слагаемом. Сначала производные по . Сигмоида обладает свойством . Отсюда:
Вторую формулу получите сами из первой: замените на и вспомните, что . Обе формулы — это одна запись:
«Предсказание минус метка» — тот же вид, что у логистической регрессии и у softmax (). Если модель уверенно права (), градиент почти ноль, если уверенно ошибается — почти .
Осталось цепное правило через : и . Обозначим и :
Словами: вектор центра тянется к настоящему контексту (у знак минус, шаг спуска идёт против градиента) и отталкивается от негативов. Вектор контекста тянется к центру, векторы негативов от него отталкиваются.
Пример. , один негатив: , , .
- , , .
- , , .
- .
- ; ; .
Один шаг SGD с даёт , , , и 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 — наш , сразу умноженный на шаг обучения alpha. Знак обратный:
Миколов прибавляет g, то есть поднимается по логарифму правдоподобия, а мы спускаемся по loss. Сигмоида берётся из
заранее посчитанной таблицы expTable на 1000 значений: в 2013 году экспонента на каждом шаге казалась дорогой. При
сигмоиду считают ровно 0 или 1.
Шаг SGD: разреженное обновление
Заголовок раздела «Шаг SGD: разреженное обновление»Шаг для одной пары меняет одну строку и строк . Все остальные строки не трогаются: из
100 тысяч строк обновляются семь. Поэтому word2vec обучается быстро и хорошо параллелится. word2vec.c запускает
несколько потоков, которые пишут в общие таблицы без блокировок. Изредка два потока одновременно правят одну
строку, и одно из обновлений частично теряется, но при разреженных обновлениях это почти не вредит.
Мы будем обновлять сразу батч из пар — так 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) почти до нуля
к концу обучения: . Это простейшее расписание learning rateрасписание learning rateEN learning rate scheduleПравило, по которому learning rate меняется по ходу обучения — разогрев, плато, спад. из урока 1.4.2.
В нашем задании будет так же.
Конвейер данных word2vec
Заголовок раздела «Конвейер данных word2vec»Модель простая, и качество во многом решает подготовка данных. Четыре шага:
- Словарь. Слова, встретившиеся реже
min_countраз (вword2vec.cпо умолчанию 5), выбрасывают: по паре вхождений хороший вектор не выучить, а словарь раздувается. - прореживание частых словпрореживание частых словEN subsampling of frequent wordsПеред обучением каждое вхождение слова w выбрасывают с вероятностью 1 − √(t / f(w)), где f(w) — доля слова в корпусе. «и», «в», «на» теряют большую часть вхождений, редкие слова не трогаются.. Слова «и», «в», «на» стоят рядом почти со всем и мало говорят о смысле соседей. Каждое вхождение слова выбрасывают с вероятностью где — доля слова в корпусе, а — порог (в статье ). Слово с долей 5% теряет вхождений, слово с долей 0.1% — 90%, а слова с долей меньше остаются все. Корпус становится короче и «содержательнее», а окно ±5 после чистки дотягивается до более далёких значимых слов.
- Пары с динамическим окном.
word2vec.cдля каждого центрального слова выбирает размер окна случайно от 1 доwindow. Ближние соседи попадают в пары чаще дальних, и это неявно придаёт им больший вес. - шумовое распределениешумовое распределениеEN noise distributionРаспределение, из которого берут негативы. В word2vec — частоты слов в степени 3/4: частые слова берутся реже, чем пропорционально частоте, редкие — чаще.. Негативы берут пропорционально частоте в степени . Пример: два слова с частотами 900 и 100. Пропорционально частоте их брали бы в 90% и 10% случаев, а после степени : и , то есть 83.9% и 16.1%. Редкие слова попадают в негативы чаще, чем по частоте, но частые всё равно доминируют. Степень Миколов подобрал экспериментально.
flowchart LR T["Текст"] --> V["Словарь<br/>min_count"] --> S["Прореживание<br/>частых слов"] --> P["Пары (центр, контекст)<br/>динамическое окно"] P --> N["+ k негативов<br/>из частоты^(3/4)"] --> G["Шаг SGD<br/>7 строк из V"]
Обучаем на маленьком русском корпусе
Заголовок раздела «Обучаем на маленьком русском корпусе»В задании 114 коротких предложений на пять тем: животные, напитки, города, семья, времена года. Это около 560 слов. Слова специально стоят в одной форме («кошка спит», «кошка ловит»): иначе «кошка», «кошку» и «кошке» стали бы тремя разными словами со своими редкими счётчиками (вспомните проблему морфологии из 4.3.1). Прореживание здесь выключено: корпус слишком мал, и при союз «и» исчез бы почти целиком.
Вы реализуете пять функций: устойчивую сигмоиду, извлечение пар, распределение шума, loss с градиентами и шаг SGD со scatter-add. Цикл обучения, словарь и поиск соседей уже написаны: , окно 2, , батч 32, 40 эпох, с линейным спадом.
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)]Первая эпоха начинается ровно с — так и должно быть при . «Кошка» нашла «кота», «чай» — «кофе», «мама» — «папу», а «зимой» — «осенью». На 560 словах соседи шумные: рядом с «чаем» оказался «папа», потому что в корпусе «папа пьёт кофе» и «мама пьёт чай» стоят в одинаковых окружениях. Тесты проверяют главное: похожие слова ближе непохожих с заметным запасом.
Попробуйте сами:
- Поставьте
k=1иk=15. Как меняются стартовый loss () и качество соседей? - Замените в
trainслучайную инициализацию нулями. Что покажет loss? Сверьтесь с врезкой выше. - Сравните соседей по и по (для этого
trainпридётся вернуть и ). Сумму двух таблиц использует 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 — это наша , model.syn1neg — (имя взято прямо из 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: настоящая пара против случайных. Замените «слово и его контекст» на «вопрос и абзац с ответом» — получится обучение эмбеддинг-моделей для RAG. Замените на «картинку и её подпись» — получится CLIP. Это и есть контрастивное обучениеконтрастивное обучениеEN contrastive learningсближаем представления «правильных пар» и раздвигаем «неправильные»:
| Положительная пара | Откуда негативы | Функция потерь | |
|---|---|---|---|
| word2vec (2013) | слово и слово из его окна | случайные слова из частоты | сигмоида на каждую пару |
| SimCSE (2021) | одно предложение, пропущенное через сеть дважды с разным dropout | чужие предложения батча | softmax по батчу (InfoNCE) |
| BGE, E5, Qwen3-Embedding | запрос и подходящий фрагмент | фрагменты из батча + трудные негативы | InfoNCE |
| CLIP (2021) | картинка и её подпись | чужие подписи батча | InfoNCE в обе стороны |
| SigLIP (2023) | картинка и её подпись | все чужие пары батча | сигмоида на каждую пару |
Что изменилось со времён word2vec:
- Негативы из батча (негативы из батчанегативы из батчаEN in-batch negativesВ контрастивном обучении негативами для пары служат чужие примеры того же батча: батч из B пар даёт каждой паре B − 1 негатив бесплатно.) вместо шумового распределения. Батч из пар даёт каждой паре негатив бесплатно: их векторы всё равно уже посчитаны.
- Softmax по кандидатам вместо отдельных сигмоид: InfoNCEInfoNCE (контрастивная функция потерь)EN InfoNCE losscross-entropy по матрице сходств, где правильные пары стоят на диагонали — это cross-entropy, где «классы» — кандидаты, а правильный — настоящая пара. Формулу вы увидите в 8.2.1 и 9.1.1. Обратите внимание на последнюю строку таблицы: Google в SigLIP вернулся к сигмоиде на каждую пару, и это снова логистическая регрессия, как у Миколова.
- Трудные негативытрудные негативыEN hard negativesНегативы, похожие на правильный ответ (фрагмент на ту же тему, но не отвечающий на вопрос). Учат тонким различиям; ими дообучают эмбеддинг-модели для поиска.. Случайный негатив почти всегда очевидно чужой: модель быстро учится его отталкивать, и градиент исчезает. Абзац на ту же тему, но без ответа на вопрос, учит тонким различиям. Такие негативы ищут специально — например, берут самые похожие фрагменты, которые находит предыдущая версия модели.
- Энкодер вместо таблицы. Вектор текста считает трансформер (урок 4.5.4), поэтому он зависит от порядка слов и контекста. Таблица эмбеддингов осталась только на входе.
Глубже: что на самом деле выучивает SGNS
Омер Леви и Йоав Голдберг (2014) показали, что в оптимуме skip-gram с негативным сэмплированием векторы удовлетворяют
точечная взаимная информацияточечная взаимная информация (PMI)EN pointwise mutual information (PMI)PMI(w, c) = log P(w, c) / (P(w) P(c)) — во сколько раз чаще слова встречаются вместе, чем если бы были независимы. Skip-gram с негативным сэмплированием неявно приближает PMI − log k. показывает, во сколько раз чаще слова встречаются вместе, чем если бы были независимы. Значит, word2vec неявно раскладывает матрицу «слово × контекст» со сдвинутыми PMI на произведение двух таблиц . Это почти то же, что делают счётные методы из 4.3.1 со своей матрицей счётчиков. «Предсказательный» и «счётный» пути сходятся к одному: геометрия векторов отражает статистику совместной встречаемости. Подробный разбор — у Лены Войты (раздел Word2Vec и Related Papers).
Проверь себя
Заголовок раздела «Проверь себя»- Полный softmax в skip-gram трогает все векторов контекста на каждой паре. Негативное сэмплирование сводит задачу к логистическим регрессиям и ускоряет обучение примерно в раз.
- Градиенты: , затем , . Шаг SGD
меняет строки, а повторы в батче складывают через scatter-add (
np.add.at). - Конвейер word2vec: словарь с
min_count, прореживание частых слов , динамическое окно, шум из частоты, линейно падающий lr. стартует с нулей, и стартовый loss равен . - Mean pooling даёт грубый эмбеддинг предложения, но теряет порядок слов. Современные эмбеддинг-модели (SimCSE, BGE, E5) и CLIP — наследники негативного сэмплирования: контрастивный loss, негативы из батча, трудные негативы.
Дальше: модуль 4.4 «Механизм внимания» — как токены обмениваются информацией и почему вектор «ключа» начинает зависеть от контекста — Внимание интуитивно.