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

Слова как векторы

Почему one-hot не годится, как дистрибутивная гипотеза превращается в word2vec, откуда берётся «король − мужчина + женщина ≈ королева» и почему слой nn.Embedding — это просто таблица.

УРОКstage-4/4-3-embeddings/01-word-vectors.mdx1 ч 40 минУрок 1 из 2
После урока вы сможете
  • Объяснить, почему one-hot векторы не передают сходства слов, и сформулировать дистрибутивную гипотезу
  • Описать skip-gram с негативным сэмплированием — что предсказывается, какой loss, что в итоге считается эмбеддингом
  • Найти ближайших соседей и решить аналогию по эмбеддингам через косинусное сходство
  • Показать, что nn.Embedding = one-hot × матрица = выбор строки, и посчитать размер таблицы эмбеддингов реальной LLM

Токенизатор (модуль 4.2) превращает текст в номера: у Qwen слово «кот» становится одним или несколькими id из словаря на 152 тысячи токенов. Но номер — плохой вход для нейросети. Токены 1234 и 1235 стоят рядом в словаре, но по смыслу ничем не связаны, а «кот» и «кошка» могут иметь номера, различающиеся на сотню тысяч.

Поэтому первый слой любой LLM превращает номер токена в вектор из сотен или тысяч чисел — эмбеддинг, векторное представлениеэмбеддинг, векторное представлениеEN embedding. Эти векторы обучаются вместе с моделью, и геометрия пространства начинает отражать смысл: похожие слова оказываются рядом, а направления кодируют отношения вроде «мужской → женский» или «страна → столица».

В первом уроке модуля 4.1 мы видели третью беду n-граммных моделей: знание о «коте» никак не переносится на «кошку». Эмбеддинги решают ровно эту проблему. Сегодня разберём, откуда берётся такая геометрия, обучим word2vec своими руками и увидим, что слой nn.Embedding в PyTorch устроен проще, чем кажется.

Самый прямолинейный способ сделать из номера вектор — one-hot кодированиеone-hot кодирование (унитарный код)EN one-hot encodingВектор длины V из нулей с единицей в позиции номера токена. Все one-hot векторы попарно ортогональны.: вектор длины VV из нулей с единицей на месте номера токена. Мы уже подавали его на вход биграммной нейросети во втором уроке модуля 4.1.

кот=[0,…,0,1812,0,…,0],кошка=[0,…,0,15407,0,…,0]\begin{gathered} \text{кот} = [0, \dots, 0, \underset{812}{1}, 0, \dots, 0], \\ \text{кошка} = [0, \dots, 0, \underset{5407}{1}, 0, \dots, 0] \end{gathered}

Число под единицей — её позиция, то есть номер токена в словаре (номера здесь условные).

Три проблемы:

  1. Нет сходства. Скалярное произведение любых двух разных one-hot векторов равно 0, значит косинусное сходствокосинусное сходствоEN cosine similarityКосинус угла между векторами — скалярное произведение, делённое на произведение длин. Для нормированных векторов равно скалярному произведению. тоже 0. «Кот» одинаково далёк и от «кошки», и от «трактора».
  2. Огромная размерность. При V=150 000V = 150\,000 каждый вектор — 150 тысяч чисел, из которых одно ненулевое.
  3. Нет обобщения. Всё, что модель узнала о «коте», хранится в весах, связанных с позицией 812, и никак не влияет на позицию 5407.

Нужны плотные векторы небольшой размерности dd (от 50 до нескольких тысяч), где близость векторов означает близость смысла. Вопрос: как их получить без ручной разметки?

Ответ дала лингвистика середины XX века. дистрибутивная гипотезадистрибутивная гипотезаEN distributional hypothesisСлова, которые встречаются в похожих контекстах, имеют похожий смысл (Harris, 1954; Firth, 1957). (Зеллиг Харрис, 1954) говорит: слова, которые встречаются в похожих контекстах, имеют похожий смысл. Джон Ферт (1957) сформулировал это короче: «слово узнают по его компании» (You shall know a word by the company it keeps).

Проверьте на себе:

Утром я выпил чашку горячего ___.

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

Значит, смысл слова можно извлечь из статистики его соседей. Самый прямой способ — посчитать, сколько раз каждое слово стояло рядом с каждым контекстным словом. Вот такие счётчики для окна ±2 слова на синтетическом корпусе, который мы ниже используем для обучения word2vec:

естрыбукостьспитпьётгорячийстолица
кот11968086000
кошка10252092000
собака11205199000
чай00001301990
кофе00001301980
москва000000105

Строки уже работают как векторы. Посчитаем косинус по этим семи столбцам: «кот» и «кошка» — 0.99, «кот» и «собака» — 0.85, «кот» и «чай» — 0, «чай» и «кофе» — 1.00. Настоящая матрица имеет размер V×VV \times V (сотни тысяч столбцов, почти все нули), поэтому её сжимают до dd измерений. Например, SVDсингулярное разложение (SVD)EN singular value decomposition (SVD)A = UΣVᵀ — любую матрицу можно разложить на поворот, растяжение вдоль осей и ещё один поворот. Эквивалентно сумме слоёв ранга 1 σᵢuᵢvᵢᵀ. оставляет dd главных направлений (модуль 1.1, урок о SVD). Так устроены счётные методы: LSA (1990) применяет SVD к матрице «слово × документ», а GloVe (Стэнфорд, 2014) подбирает векторы так, чтобы их скалярные произведения приближали логарифмы счётчиков совместной встречаемости. Word2vec пошёл предсказательным путём.

В 2013 году Томаш Миколов и коллеги из Google предложили word2vec — две простые архитектуры, которые меньше чем за сутки обучаются на корпусе из 1.6 млрд слов. Мы разберём skip-gramskip-gramEN skip-gramВариант word2vec — по центральному слову предсказываются слова контекста. В старых китайских текстах — «модель пропуска слов».: по центральному слову предсказать слова вокруг него.

Возьмём предложение «кот спит на тёплом диване» и окно ±2. Для центрального слова «на» получаем обучающие пары (центр, контекст): (на, кот), (на, спит), (на, тёплом), (на, диване). И так для каждой позиции в каждом предложении корпуса.

У каждого слова два вектора: vwv_w — когда слово в центре, uwu_w — когда оно в контексте. Вероятность увидеть контекстное слово oo рядом с центральным cc — softmax от скалярных произведений:

P(o∣c)=exp⁡(uo⊤vc)∑w=1Vexp⁡(uw⊤vc)P(o \mid c) = \frac{\exp(u_o^\top v_c)}{\sum_{w=1}^{V} \exp(u_w^\top v_c)}

Запись uo⊤vcu_o^\top v_c — это просто скалярное произведениескалярное произведениеEN dot product (inner product)a · b = Σ aᵢbᵢ = ‖a‖‖b‖cos θ. Основа нейрона, внимания (q · k) и выходного слоя LLM. векторов uou_o и vcv_c (модуль 1.1): чем лучше они «смотрят в одну сторону», тем оно больше. Значит, формула — обычный softmax из модуля 1.3, где логит слова ww равен uw⊤vcu_w^\top v_c. Мини-пример: словарь из трёх слов, и скалярные произведения с vcv_c равны [2,0,0][2, 0, 0]. Тогда P=[e2,1,1]/(e2+2)≈[0.79,0.11,0.11]P = [e^2, 1, 1] / (e^2 + 2) \approx [0.79, 0.11, 0.11]: первое слово — самый вероятный сосед.

Обучаем, как всегда, минимизируя NLL правильных пар. Чтобы предсказывать похожие контексты, слова с похожими контекстами вынуждены получить похожие векторы vv — это и есть дистрибутивная гипотеза, превращённая в loss.

flowchart LR
A["Центральное слово<br/>«кот»"] --> B["v_кот<br/>(строка таблицы W_in)"]
B --> C["Скалярные произведения<br/>с u контекстов"]
C --> D["σ(u_ест · v_кот) → 1<br/>настоящий контекст"]
C --> E["σ(u_столица · v_кот) → 0<br/>k случайных слов"]
Skip-gram с негативным сэмплированием: настоящие пары притягиваются, случайные отталкиваются

Знаменатель softmax — сумма по всему словарю. При VV в сотни тысяч и миллиардах пар это слишком дорого. Во второй статье Миколов с соавторами заменили задачу более дешёвой — негативное сэмплированиенегативное сэмплированиеEN negative samplingВместо softmax по всему словарю модель учится отличать настоящую пару (слово, контекст) от k случайных «шумовых» пар.: вместо «какое из VV слов — контекст?» модель решает бинарную задачу «эта пара настоящая или случайная?». Для каждой настоящей пары (c,o)(c, o) берём kk случайных «шумовых» слов n1,…,nkn_1, \dots, n_k:

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)

Здесь σ(x)=1/(1+e−x)\sigma(x) = 1 / (1 + e^{-x}) — сигмоида из логистической регрессии (модуль 2.2). Она превращает скалярное произведение в вероятность «пара настоящая». Поскольку σ(−x)=1−σ(x)\sigma(-x) = 1 - \sigma(x), второе слагаемое — это −log⁡-\log вероятности того, что случайная пара будет признана фальшивой. Получается обычная бинарная cross-entropy: одна пара с меткой 1 и kk пар с меткой 0. Вместо суммы по VV словам — всего k+1k + 1 скалярное произведение.

Числовой пример: если uo⊤vc=2u_o^\top v_c = 2, то σ(2)≈0.88\sigma(2) \approx 0.88 и первое слагаемое равно −log⁡0.88≈0.13-\log 0.88 \approx 0.13. Если случайное слово даёт un⊤vc=2u_n^\top v_c = 2, его вклад −log⁡σ(−2)≈2.13-\log \sigma(-2) \approx 2.13 — большой штраф, и градиент оттолкнёт векторы друг от друга.

Первое слагаемое тянет vcv_c и uou_o друг к другу, второе отталкивает vcv_c от случайных слов. Градиенты этого loss мы выведем руками в следующем уроке. Детали из статьи:

  • kk от 5 до 20 для небольших корпусов и 2–5 для больших;
  • шумовые слова берут не равномерно, а пропорционально частоте в степени 3/43/4: это немного поднимает шансы редких слов;
  • самые частые слова («и», «на», «в») прореживают — они мало говорят о смысле соседей.

Вторая архитектура, CBOWCBOW (непрерывный мешок слов)EN continuous bag of words (CBOW)Вариант word2vec — по словам контекста предсказывается центральное слово., делает наоборот: по среднему векторов контекста предсказывает центральное слово. Она быстрее, а skip-gram лучше работает с редкими словами.

Сгенерируем синтетический корпус, где похожие по смыслу слова стоят в похожих контекстах, и обучим skip-gram с негативным сэмплированием на PyTorch. Обучение занимает несколько секунд на CPU.

Две новые конструкции Python в коде. Запись (a := rng.choice(animals)) («морж», walrus operator) вычисляет значение, сохраняет его в переменную a и тут же подставляет: так в одном шаблоне «кот ест …» еда подбирается к тому же животному. И B в комментариях вида (B, d) — размер батча, как в модуле 3.3.

import random
import torch
import torch.nn.functional as F
torch.set_num_threads(1) # для крошечных тензоров один поток быстрее
# 1. Синтетический корпус: похожие по смыслу слова стоят в похожих контекстах
rng = random.Random(0)
animals = ["кот", "кошка", "собака", "пёс"]
food = {"кот": ["рыбу", "мясо"], "кошка": ["рыбу", "мясо"],
"собака": ["мясо", "кость"], "пёс": ["мясо", "кость"]}
people = ["мама", "папа", "бабушка", "дедушка"]
drinks = ["чай", "кофе", "сок", "молоко"]
capitals = {"москва": "россии", "пекин": "китая", "париж": "франции", "токио": "японии"}
templates = [
lambda: f"{(a := rng.choice(animals))} ест {rng.choice(food[a])}",
lambda: f"{rng.choice(animals)} спит на диване",
lambda: f"{rng.choice(people)} пьёт {rng.choice(drinks)} утром",
lambda: f"{rng.choice(people)} любит горячий {rng.choice(['чай', 'кофе'])}",
lambda: f"{(c := rng.choice(list(capitals)))} столица {capitals[c]}",
lambda: f"мы летим в {rng.choice(list(capitals))} летом",
lambda: f"{rng.choice(list(capitals))} большой красивый город",
]
corpus = [rng.choice(templates)().split() for _ in range(3000)] # 3000 случайных предложений-списков слов
vocab = sorted({w for s in corpus for w in s}) # все разные слова корпуса
stoi = {w: i for i, w in enumerate(vocab)}
V = len(vocab)
print("предложений:", len(corpus), "| словарь:", V, "| пример:", " ".join(corpus[0]))
# 2. Обучающие пары (центр, контекст) в окне ±2 слова:
# для каждого слова s[i] берём соседей s[j] с j от i-2 до i+2, кроме самого i
pairs = torch.tensor([(stoi[s[i]], stoi[s[j]])
for s in corpus for i in range(len(s))
for j in range(max(0, i - 2), min(len(s), i + 3)) if j != i])
print("пар (центр, контекст):", len(pairs))
# 3. Skip-gram с негативным сэмплированием (SGNS)
torch.manual_seed(0)
d, k, epochs = 16, 5, 40
freq = torch.bincount(pairs[:, 0], minlength=V).float() # bincount: сколько раз встретилось каждое число 0…V-1
noise = freq**0.75 / (freq**0.75).sum() # негативы ~ частота^0.75, как в word2vec
emb_in = torch.nn.Embedding(V, d) # векторы слов-центров — это и есть эмбеддинги
emb_out = torch.nn.Embedding(V, d) # векторы слов-контекстов
opt = torch.optim.Adam([*emb_in.parameters(), *emb_out.parameters()], lr=0.03)
for epoch in range(1, epochs + 1):
perm = torch.randperm(len(pairs)) # случайная перестановка номеров пар — перемешиваем данные
# k случайных «шумовых» слов на каждую пару; view(-1, k) — форма (число пар, k)
negatives = torch.multinomial(noise, len(pairs) * k, replacement=True).view(-1, k)
for b in range(0, len(pairs), 512): # мини-батчи по 512 пар
c, o = pairs[perm[b:b + 512]].T # .T: из (B, 2) получаем две строки — центры c и контексты o
v = emb_in(c) # (B, d)
pos = (v * emb_out(o)).sum(-1) # (B,) «свой» контекст
# (B, k, d) @ (B, d, 1) → (B, k, 1): скалярные произведения v с каждым из k шумовых векторов
neg = (emb_out(negatives[b:b + 512]) @ v.unsqueeze(-1)).squeeze(-1) # (B, k) «чужие»
# logsigmoid(x) = log σ(x); это формула L из текста, усреднённая по батчу
loss = -(F.logsigmoid(pos) + F.logsigmoid(-neg).sum(-1)).mean()
opt.zero_grad()
loss.backward()
opt.step()
if epoch % 10 == 0:
print(f"эпоха {epoch}: loss {loss.item():.3f}")
# 4. Ближайшие соседи по косинусному сходству
E = F.normalize(emb_in.weight.detach(), dim=1) # каждую строку делим на её длину → скалярное произведение = косинус
for w in ["кот", "чай", "москва", "мама"]:
sims = E @ E[stoi[w]] # косинусы слова w со всеми словами словаря
best = sims.argsort(descending=True)[1:4] # [0] — само слово (косинус 1), берём следующие три
print(f"{w:7} → " + ", ".join(f"{vocab[i]} ({sims[i]:.2f})" for i in best))
предложений: 3000 | словарь: 39 | пример: токио большой красивый город
пар (центр, контекст): 28240
эпоха 10: loss 1.259
эпоха 20: loss 1.253
эпоха 30: loss 1.250
эпоха 40: loss 1.236
кот → кошка (0.98), собака (0.82), пёс (0.80)
чай → кофе (0.89), сок (0.72), молоко (0.68)
москва → париж (0.80), пекин (0.79), токио (0.77)
мама → папа (0.94), бабушка (0.88), дедушка (0.87)

Модель не знала ничего, кроме того, какие слова стоят рядом. Но кошачьи оказались рядом с кошачьими, напитки — с напитками, столицы — со столицами, родственники — с родственниками. Обратите внимание: «москва» и «пекин» ни разу не встретились в одном предложении. Они похожи, потому что встречаются в одинаковых окружениях («… столица …», «мы летим в …»). Это дистрибутивная гипотеза в действии.

Почему loss почти не падает после 10-й эпохи? Корпус шаблонный: рядом с «пьёт» с равной вероятностью стоит любой из четырёх напитков, и эту неопределённость никакие векторы не уберут. Loss упирается в неустранимую случайность данных (её мера — энтропияэнтропия (информационная)EN (Shannon) entropyH(p) = −Σ p log p — среднее «удивление», мера неопределённости распределения. Максимальна у равномерного распределения.), а не в ёмкость модели. К тому же мы печатаем loss последнего батча, а он шумит от батча к батчу.

Векторы из 16 чисел глазами не увидеть, но их можно спроецировать на плоскость. Допишите в конец скрипта (нужен matplotlib, модуль 0.3):

# 5. Проекция на плоскость: два главных направления из SVD (модуль 1.1)
import matplotlib.pyplot as plt
X = emb_in.weight.detach()
X = X - X.mean(dim=0) # центрируем облако точек
U, S, Vh = torch.linalg.svd(X, full_matrices=False)
xy = X @ Vh[:2].T # координаты вдоль двух главных направлений
print(f"доля разброса в 2D: {(S[:2] ** 2).sum() / (S ** 2).sum():.2f}")
plt.scatter(xy[:, 0], xy[:, 1])
for i, w in enumerate(vocab):
plt.annotate(w, (xy[i, 0].item(), xy[i, 1].item()))
plt.show()
доля разброса в 2D: 0.32

На картинке животные собираются в одном углу, столицы — в другом. А вот люди и напитки слипаются в одно облако, хотя по косинусу в 16 измерениях «мама» ближе всего к «папе», а «чай» — к «кофе». Две оси сохранили лишь треть разброса, и часть различий пропала. Отсюда правило: 2D-картинки эмбеддингов (PCAметод главных компонент (PCA)EN principal component analysis (PCA), t-SNEt-SNE (стохастическое вложение соседей)EN t-distributed stochastic neighbor embedding (t-SNE)Метод визуализации, сохраняющий соседство точек. Размеры кластеров и расстояния между далёкими кластерами на картинке t-SNE почти ничего не значат., модуль 2.3) хороши для интуиции, а выводы о сходстве делайте по косинусу в полном пространстве.

Самый знаменитый результат word2vec — отношения между словами превращаются в направления:

король→−мужчина→+женщина→≈королева→\overrightarrow{\text{король}} - \overrightarrow{\text{мужчина}} + \overrightarrow{\text{женщина}} \approx \overrightarrow{\text{королева}}

Интуиция: разность королева→−король→\overrightarrow{\text{королева}} - \overrightarrow{\text{король}} кодирует «мужской → женский», и та же разность женщина→−мужчина→\overrightarrow{\text{женщина}} - \overrightarrow{\text{мужчина}} кодирует то же самое. Если направления совпадают, то, прибавив к «королю» сдвиг «мужчина → женщина», мы попадём в окрестность «королевы». Так же работают «страна → столица» (москва→−россия→+китай→≈пекин→\overrightarrow{\text{москва}} - \overrightarrow{\text{россия}} + \overrightarrow{\text{китай}} \approx \overrightarrow{\text{пекин}}) и грамматические отношения («идти → шёл» как «бежать → бежал»).

аналогии слованалогии слов (векторная арифметика)EN word analogyЗадача «a относится к b, как c к ?», решается поиском слова, ближайшего к вектору b − a + c. «a относится к b, как c к ?» решают так:

  1. Вычислить x=b⃗−a⃗+c⃗x = \vec{b} - \vec{a} + \vec{c} (обычно из нормированных векторов).
  2. Найти слово с максимальным косинусом к xx.
  3. Исключить сами a, b и c. Без этого ответом часто оказывается bb или cc: например, «король» ближе всех к король→−мужчина→+женщина→\overrightarrow{\text{король}} - \overrightarrow{\text{мужчина}} + \overrightarrow{\text{женщина}}. Это важная оговорка — эффектные примеры аналогий отчасти держатся на этом исключении.

У word2vec один вектор на слово, независимо от контекста. Отсюда проблемы:

  • Многозначность (многозначностьмногозначность (полисемия)EN polysemyОдно слово с несколькими значениями («ключ», «коса», «лук»). Статический эмбеддинг даёт им один вектор на все значения.). «Ключ» от двери, «ключ» — родник и скрипичный «ключ» получают один вектор — смесь всех значений. В китайском одно и то же слово означает и яблоко, и компанию Apple.
  • Морфология. «кот», «кота», «коту», «котом», «коты», «котов» для word2vec — шесть разных слов, и каждое учится отдельно. Для русского с его падежами это особенно больно. Поэтому русские word2vec-модели (в том числе на RusVectores) часто обучают на лемматизированных текстах, где все формы сведены к начальной («кот»), а fastText (Facebook, 2017) собирает вектор слова из векторов его буквенных n-грамм.
  • Новые слова. Слова, которого не было в обучении, у модели просто нет.

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

Теперь главное для инженера. В LLM нет отдельно обученного word2vec. Первый слой — слой эмбеддинговслой эмбеддингов (таблица эмбеддингов)EN embedding layerМатрица V × d; по номеру токена возвращает его строку. Эквивалентно one-hot × матрица. В PyTorch — nn.Embedding.: матрица WW размера V×dV \times d, инициализированная случайно и обучаемая вместе со всей моделью. По номеру токена слой возвращает строку матрицы.

В уроке о биграммах мы видели, что one-hot × матрица — это выбор строки. nn.Embedding делает то же самое, но без умножения на огромный разреженный вектор, просто индексацией:

import torch
import torch.nn.functional as F
torch.manual_seed(0)
emb = torch.nn.Embedding(num_embeddings=5, embedding_dim=3) # словарь 5 токенов, векторы длины 3
print(emb.weight.shape)
ids = torch.tensor([2, 0, 2]) # три токена: «2», «0», снова «2»
a = emb(ids) # lookup: строки 2, 0, 2
b = F.one_hot(ids, num_classes=5).float() @ emb.weight
print(torch.allclose(a, b)) # то же самое, что one-hot × матрица
a.sum().backward()
print(emb.weight.grad) # градиент пришёл только в строки 0 и 2
batch = torch.tensor([[1, 4, 2, 0], [3, 3, 1, 2]]) # (B=2, T=4) — номера токенов
print(emb(batch).shape) # (B, T, d) — вход трансформера
torch.Size([5, 3])
True
tensor([[1., 1., 1.],
[0., 0., 0.],
[2., 2., 2.],
[0., 0., 0.],
[0., 0., 0.]])
torch.Size([2, 4, 3])

Три вывода:

  1. Lookup = one-hot × матрица. Математически это линейный слойлинейный (полносвязный) слойEN linear layer (fully connected layer)y = xWᵀ + b. В PyTorch nn.Linear(in, out) хранит weight формы (out, in) — частый источник путаницы. без сдвига, а на практике — индексация.
  2. Градиент разреженный. Обновляются только строки токенов, которые были в батче. Строка 2 встретилась дважды и получила градиент 2 — вклады складываются. Редкие токены обновляются редко, поэтому их эмбеддинги плохо обучены — отсюда «глючные» токены вроде SolidGoldMagikarp из модуля 4.2.
  3. Форма (B,T)→(B,T,d)(B, T) \to (B, T, d). Батч номеров токенов превращается в батч векторов. С этого тензора начинается каждый трансформер.

Таблица эмбеддингов — V⋅dV \cdot d параметров:

МодельVVddПараметров в таблице
Наш skip-gram3916624
Qwen3-0.6B151 9361 024≈ 156 млн — около четверти модели
DeepSeek-V3129 2807 168≈ 927 млн

У DeepSeek-V3 одна только входная таблица — почти миллиард параметров, около 1.85 ГБ в BF16. Но на каждый токен из неё читается одна строка, поэтому вычислений она почти не требует. У маленьких моделей таблица — заметная доля всех весов, поэтому Qwen3-0.6B использует связанные весасвязывание весов (общие эмбеддинги входа и выхода)EN weight tying (tied embeddings)Одна и та же матрица служит эмбеддингом токенов и LM head. Её градиент — сумма вкладов от обоих мест. В Hugging Face — флаг tie_word_embeddings. (weight tying): та же матрица работает и выходным слоем, который превращает скрытое состояние в логиты по словарю. Подробно — в модуле 4.6.

Найдите ближайших соседей и решите аналогии на игрушечных эмбеддингах. Векторы собраны из смысловых признаков («королевский», «мужской», «столица», «Китай»…), к ним добавлен шум, а оси спрятаны случайным поворотом. Как и в настоящих эмбеддингах, отдельные координаты ничего не значат, смысл живёт в углах между векторами.

Ближайшие соседи и аналогииnumpy · нужен интернет6 тестов
import numpy as np

# ── Игрушечные эмбеддинги (готово, менять не нужно) ─────────────────────────────
# У каждого слова есть «рецепт» из смысловых признаков. Мы добавляем шум и прячем оси
# случайным поворотом: как и в настоящих эмбеддингах, отдельные координаты ничего не значат,
# смысл живёт только в углах и расстояниях между векторами.
AXES = ["королевский", "мужской", "взрослый", "человек", "животное", "кошачий", "собачий",
        "город", "столица", "страна", "Россия", "Китай", "Франция", "Япония",
        "напиток", "горячий", "еда"]
RECIPES = {
    "король": "королевский мужской взрослый человек",
    "королева": "королевский мужской:-1 взрослый человек",
    "принц": "королевский мужской взрослый:0.2 человек",
    "принцесса": "королевский мужской:-1 взрослый:0.2 человек",
    "мужчина": "мужской взрослый человек",
    "женщина": "мужской:-1 взрослый человек",
    "мальчик": "мужской взрослый:0.1 человек",
    "девочка": "мужской:-1 взрослый:0.1 человек",
    "кот": "животное кошачий мужской:0.3",
    "кошка": "животное кошачий мужской:-0.3",
    "пёс": "животное собачий мужской:0.3",
    "собака": "животное собачий мужской:-0.3",
    "москва": "город столица Россия",
    "петербург": "город Россия",
    "россия": "страна Россия",
    "пекин": "город столица Китай",
    "шанхай": "город Китай",
    "китай": "страна Китай",
    "париж": "город столица Франция",
    "франция": "страна Франция",
    "токио": "город столица Япония",
    "япония": "страна Япония",
    "чай": "напиток горячий Китай:0.3",
    "кофе": "напиток горячий",
    "сок": "напиток",
    "молоко": "напиток еда:0.3",
    "рис": "еда Китай:0.3",
    "хлеб": "еда Россия:0.3",
}


def make_embeddings(dim=20, noise=0.08, seed=0):
    rng = np.random.default_rng(seed)
    words = list(RECIPES)
    E = np.zeros((len(words), dim))
    for i, w in enumerate(words):
        for part in RECIPES[w].split():
            name, _, val = part.partition(":")
            E[i, AXES.index(name)] = float(val) if val else 1.0
    E += noise * rng.standard_normal(E.shape)
    Q, _ = np.linalg.qr(rng.standard_normal((dim, dim)))  # случайный поворот
    return words, E @ Q


WORDS, E = make_embeddings()  # WORDS — список из 28 слов, E — матрица 28 × 20


# ── Ваши функции ───────────────────────────────────────────────────────────────
def normalize(E):
    """Делит каждую строку E на её L2-норму. Возвращает новую матрицу (E не меняет)."""
    # TODO: ваш код
    ...


def most_similar(word, words, E, k=3):
    """k ближайших к word слов по косинусному сходству, без самого word.

    Возвращает список пар (слово, сходство) по убыванию сходства.
    """
    # TODO: ваш код
    ...


def analogy(a, b, c, words, E):
    """Решает аналогию «a относится к b, как c к ?».

    Строит вектор b − a + c из НОРМИРОВАННЫХ эмбеддингов и возвращает ближайшее к нему
    по косинусу слово, исключая сами a, b и c.
    Пример: analogy("мужчина", "король", "женщина", ...) → "королева".
    """
    # TODO: ваш код
    ...


def one_hot(ids, V):
    """Матрица len(ids) × V из нулей и единиц: в строке r единица стоит в столбце ids[r]."""
    # TODO: ваш код
    ...


# Кнопка «Запустить»:
print("Соседи «кот»:", most_similar("кот", WORDS, E))
print("мужчина : король = женщина :", analogy("мужчина", "король", "женщина", WORDS, E))

После решения попробуйте сами: что ближе к «пекин» — «москва» или «шанхай», и почему? Какой ответ даст аналогия «кот : кошка = пёс : ?»? Сравните соседей «чай» и «кофе» при k=5: почему у «чая» в списке появляются «китай» и «пекин», а у «кофе» — нет? Загляните в RECIPES.

Проверь себя6 вопросов
1 Чему равно косинусное сходство one-hot векторов двух разных слов?
2 Что предсказывает skip-gram?
3 Какой вектор нужно построить, чтобы решить аналогию «мужчина : король = женщина : ?»
4 Выберите все верные утверждения про nn.Embedding.Можно выбрать несколько ответов
5 Сколько миллионов параметров во входной таблице эмбеддингов DeepSeek-V3 (V=129 280V = 129\,280, d=7168d = 7168)? Округлите до целого.
6 Почему word2vec не различает «ключ» от двери и «ключ» — родник?
  • One-hot векторы огромны и попарно ортогональны: сходства слов в них нет.
  • Дистрибутивная гипотеза: смысл слова определяется его контекстами. Счётные методы (LSA, GloVe) сжимают матрицу совместной встречаемости, предсказательные (word2vec) учат векторы предсказывать соседей.
  • Skip-gram с негативным сэмплированием притягивает векторы настоящих пар (слово, контекст) и отталкивает случайные. Похожие слова получают похожие векторы, отношения становятся направлениями.
  • Аналогия b−a+cb - a + c ищется по косинусу с исключением a,b,ca, b, c. Эмбеддинги наследуют и предрассудки данных.
  • nn.Embedding — таблица V×dV \times d: lookup = one-hot × матрица, градиент разреженный, форма (B,T)→(B,T,d)(B, T) \to (B, T, d). У DeepSeek-V3 в ней почти миллиард параметров.

Дальше: «Обучаем word2vec сами: skip-gram и negative sampling» — выведем градиенты негативного сэмплирования руками, обучим векторы на NumPy без автограда, как в оригинальном word2vec, и увидим, как из этой идеи выросли эмбеддинги предложений для RAG и CLIP.