Tokenization и Embeddings
Текст — это не числа
Нейросеть умеет только умножать матрицы и применять функции к числам. Чтобы она работала с текстом, его нужно превратить в числа. Путь такой:
Токен — единица текста, с которой работает модель. Словарь — список всех токенов, номер токена — его позиция в словаре. Сначала разберёмся, на что резать текст.
Токенизация: символы, слова, части слов
text = "Кошки спят. Кошка спала, а котёнок не спал."
chars = list(text)
words = text.lower().replace(".", " .").replace(",", " ,").split()
print("символы:", chars[:20], "…")
print(" токенов:", len(chars), " словарь:", len(set(chars)))
print("слова: ", words)
print(" токенов:", len(words), " словарь:", len(set(words)))
- По символам. Словарь крошечный (буквы, цифры, знаки — около сотни), незнакомых токенов не бывает. Но последовательности очень длинные, а отдельная буква почти ничего не значит — модели приходится самой собирать из букв слова.
- По словам. Каждый токен осмыслен, последовательности короткие. Но словарь огромный — особенно в русском, где у слова десятки форм: «кошка, кошки, кошке, кошку, кошкой…» — всё разные токены, и модель не знает, что они родственные. Слово, которого не было в обучающих текстах, превращается в специальный токен
[UNK](unknown) — информация теряется. - По частям слов (subword). Золотая середина: частые слова — один токен, редкие собираются из кусочков: «котёнок» → «кот» + «ёнок». Незнакомых слов нет (в худшем случае — по буквам), а родственные слова делят общие части. Так работают все современные языковые модели.
BPE: как строится словарь частей слов
Byte Pair Encoding (BPE) строит словарь подслов жадно:
- Начинаем со словаря из отдельных символов: каждое слово — список букв.
- Находим самую частую пару соседних токенов во всём корпусе.
- Сливаем её в новый токен и добавляем его в словарь.
- Повторяем, пока словарь не достигнет нужного размера.
from collections import Counter
corpus = ("кот котёнок котик кошка кошки кошке коты котов "
"нос носок носки носить носил сок соки сокол").split()
words = [list(w) + ["_"] for w in corpus] # «_» отмечает конец слова
def most_frequent_pair(words):
counts = Counter(p for w in words for p in zip(w, w[1:]))
return counts.most_common(1)[0]
def merge_pair(word, pair):
out, i = [], 0
while i < len(word):
if i + 1 < len(word) and (word[i], word[i + 1]) == pair:
out.append(word[i] + word[i + 1]); i += 2
else:
out.append(word[i]); i += 1
return out
merges = []
for step in range(12):
pair, n = most_frequent_pair(words)
merges.append(pair)
words = [merge_pair(w, pair) for w in words]
print(f"шаг {step + 1:2}: {pair[0]!r} + {pair[1]!r} → {pair[0] + pair[1]!r} (пара встретилась {n}×)")
print()
for w in words[:8]:
print(" ".join(w))
def tokenize(word):
w = list(word) + ["_"]
for pair in merges: # применяем слияния в том же порядке
w = merge_pair(w, pair)
return w
print("\nновые слова:", tokenize("котами"), tokenize("носок"), tokenize("кошками"))
После нескольких слияний появились токены «кот», «нос», «кошк» — общие части родственных слов, и окончания вроде «и_» (конец слова на «и»). Слова «котами» и «кошками», которых не было в корпусе, всё равно разбились на знакомые части: «кот» + «а» + «м» + «и_». Токенизаторы GPT делают то же самое на гигантском корпусе, работают с байтами вместо букв (так любой текст на любом языке кодируется без [UNK]) и останавливаются на словаре в 50–200 тысяч токенов. Поэтому языковые модели плохо считают буквы в словах: они видят не буквы, а куски слов.
Специальные токены и паддинг
Кроме частей текста в словаре есть служебные токены:
[PAD]— заполнитель. Тексты разной длины, а батч — прямоугольная матрица, поэтому короткие тексты дополняют паддингом до общей длины. Модели говорят маской, какие позиции — паддинг, чтобы она их игнорировала.[UNK]— неизвестный токен (в BPE почти не нужен).[CLS],[SEP]— начало текста и разделитель (в BERT);<bos>,<eos>— начало и конец последовательности (в GPT-подобных моделях).
import numpy as np
vocab = ["[PAD]", "[UNK]", "[CLS]", "кот", "спит", "собака", "лает", "громко", "на", "диване"]
stoi = {w: i for i, w in enumerate(vocab)}
texts = ["кот спит на диване", "собака лает", "кот громко мурчит"]
ids = [[stoi["[CLS]"]] + [stoi.get(w, stoi["[UNK]"]) for w in t.split()] for t in texts]
T = max(len(x) for x in ids)
batch = np.array([x + [stoi["[PAD]"]] * (T - len(x)) for x in ids])
mask = batch != stoi["[PAD]"]
print("батч номеров:\n", batch)
print("маска (True — настоящий токен):\n", mask)
Слова «мурчит» нет в словаре — оно стало [UNK] (номер 1).
One-hot: плохая идея
Самый простой способ превратить номер в вектор — one-hot: вектор длины V (размер словаря) из нулей с единицей на месте номера токена. У него две беды:
- Огромная размерность: при словаре в 50 000 — вектор из 50 000 чисел на каждый токен.
- Никакого смысла: скалярное произведение векторов разных слов всегда 0. «Кошка» так же далека от «собаки», как от «интеграла».
Эмбеддинги
Эмбеддинг (embedding, «вложение») — плотный вектор небольшой размерности d (от десятков до нескольких тысяч), который сопоставляется каждому токену. Все векторы хранятся в матрице E формы (V, d), и это обучаемые параметры: их подбирает градиентный спуск вместе с остальной сетью.
Получить эмбеддинг — значит взять строку матрицы по номеру токена. Это то же самое, что умножить one-hot вектор на E:
import numpy as np
import nanotorch as nt
nt.manual_seed(0)
emb = nt.Embedding(10, 4) # словарь из 10 токенов, векторы длины 4
ids = np.array([3, 7, 3])
print("эмбеддинги токенов 3, 7, 3:\n", emb(ids).data.round(3))
one_hot = np.eye(10)[ids] # (3, 10)
print("one-hot @ E даёт то же самое:", np.allclose(one_hot @ emb.weight.data, emb(ids).data))
Слой Embedding — это линейный слой без bias, который вместо умножения на one-hot просто выбирает строку: результат тот же, но гораздо быстрее. При обратном проходе градиент получают только строки тех токенов, которые были в батче.
Учим эмбеддинги по контексту
Откуда векторы узнают смысл? Из дистрибутивной гипотезы: «слово узнают по его окружению». Слова, которые встречаются в похожих контекстах, похожи по смыслу. «Кошка ест рыбу» и «собака ест рыбу» — значит, кошка и собака в чём-то похожи.
Идея word2vec (Миколов, 2013): обучим сеть по слову предсказывать соседние слова. Сеть — это просто эмбеддинг и линейный слой. Чтобы хорошо предсказывать соседей, слова с похожими соседями должны получить похожие векторы.
Сгенерируем маленький корпус из простых предложений про животных, еду, города и цвета и обучим эмбеддинги размерности 8:
import numpy as np
import nanotorch as nt
import vis
rng = np.random.default_rng(0)
animals = ["кошка", "собака", "лошадь", "корова", "мышь"]
foods = ["рыбу", "мясо", "хлеб", "сыр", "кашу"]
cities = ["москва", "париж", "лондон", "рим", "берлин"]
colors = ["красный", "синий", "зелёный", "жёлтый", "белый"]
templates = [
lambda: f"{rng.choice(animals)} ест {rng.choice(foods)}",
lambda: f"{rng.choice(animals)} спит и {rng.choice(animals)} спит",
lambda: f"моя {rng.choice(animals)} любит {rng.choice(foods)}",
lambda: f"город {rng.choice(cities)} очень большой",
lambda: f"я живу в городе {rng.choice(cities)}",
lambda: f"столица {rng.choice(cities)} красивая",
lambda: f"на завтрак я ем {rng.choice(foods)}",
lambda: f"вкусный {rng.choice(foods)} на столе",
lambda: f"цвет {rng.choice(colors)} яркий",
lambda: f"я купил {rng.choice(colors)} шарф",
lambda: f"небо стало {rng.choice(colors)}",
]
corpus = [templates[rng.integers(len(templates))]().split() for _ in range(3000)]
print("примеры:", [" ".join(s) for s in corpus[:3]])
vocab = sorted({w for s in corpus for w in s})
stoi = {w: i for i, w in enumerate(vocab)}
# пары (слово, сосед) — соседи на расстоянии до 2 слов
pairs = np.array([(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 i != j])
print("словарь:", len(vocab), "слов, обучающих пар:", len(pairs))
nt.manual_seed(0)
model = nt.Sequential(nt.Embedding(len(vocab), 8), nt.Linear(8, len(vocab)))
nt.fit(model, pairs[:, 0], pairs[:, 1], nt.cross_entropy, nt.Adam(model.parameters(), lr=0.01),
epochs=3, batch_size=256, verbose=False)
E = model.layers[0].weight.data
En = E / np.linalg.norm(E, axis=1, keepdims=True)
for w in ["кошка", "москва", "сыр", "синий"]:
sims = En @ En[stoi[w]]
best = [j for j in np.argsort(-sims) if j != stoi[w]][:3]
print(f"{w:7} → " + ", ".join(f"{vocab[j]} ({sims[j]:.2f})" for j in best))
groups = [animals, foods, cities, colors]
shown = [w for g in groups for w in g]
P = E[[stoi[w] for w in shown]]
P = P - P.mean(axis=0)
_, _, Vt = np.linalg.svd(P, full_matrices=False) # PCA: проекция на две главные компоненты
xy = P @ Vt[:2].T
vis.scatter(xy, labels=[k for k, g in enumerate(groups) for _ in g], texts=shown,
title="Эмбеддинги слов (проекция PCA на плоскость)")
Никто не говорил модели, что кошка и собака — животные, а Рим и Париж — города. Она узнала это только из того, какие слова стоят рядом. На проекции слова одной группы собрались вместе (PCA из урока о собственных векторах).
Эмбеддинги в трансформерах
В больших моделях эмбеддинги не обучают отдельно, как в word2vec, а учат вместе со всей сетью под её задачу. У них есть важное ограничение: эмбеддинг токена один и тот же в любом контексте. «Ключ» от замка и «ключ» — родник получают одинаковый вектор. Различить их можно только посмотрев на соседние слова — и именно этим займётся механизм внимания в следующем уроке.
Ещё одно: эмбеддинг ничего не знает о позиции токена. «Собака кусает человека» и «человек кусает собаку» — один и тот же набор векторов. Как добавить в них порядок, разберём в уроке про энкодер трансформера.
Итог
- Текст → токены → номера по словарю → векторы-эмбеддинги.
- Токенизация по символам — маленький словарь и длинные последовательности; по словам — огромный словарь и
[UNK]; по частям слов (BPE) — компромисс, который используют современные модели. - BPE начинает с символов и раз за разом сливает самую частую пару соседних токенов.
- Эмбеддинг — обучаемая строка матрицы E формы (V, d); это то же, что умножение one-hot вектора на E.
- Слова с похожим окружением получают похожие векторы; сходство измеряют косинусом.
Практика решено 0 из 7
Какой способ токенизации обладает каждым свойством?
| символы | слова | BPE | |
|---|---|---|---|
| Самый маленький словарь | |||
| Самые длинные последовательности | |||
| Незнакомое слово превращается в [UNK] | |||
| Частые слова — один токен, редкие разбиваются на части | |||
| Используется в GPT, BERT и других современных моделях |
Чему равно скалярное произведение one-hot векторов двух разных слов?
Словарь — 50 000 токенов, размер эмбеддинга — 768. Сколько обучаемых параметров в таблице эмбеддингов?
Матрица эмбеддингов E имеет форму (V, d), а e_i — one-hot вектор токена i. Чему равно e_i^\top E?
Шаг BPE: самая частая пара. Напишите most_frequent_pair(words): на вход — список слов, каждое слово — список
токенов (строк). Посчитайте, сколько раз каждая пара соседних токенов встречается во всех словах, и верните
самую частую пару как кортеж (a, b). При равенстве верните пару, которая встретилась первой.
Какие тесты проверяют код (2)
words = [list("нос"), list("носок"), list("сок")]
assert most_frequent_pair(words) in (("н", "о"), ("о", "с")) , "Пары «н о» и «о с» встречаются по 2 раза"
assert most_frequent_pair(words) == ("н", "о"), "При равенстве — пара, встретившаяся первой: «н о»"
words = [list("абаба"), list("ба")]
assert most_frequent_pair(words) == ("б", "а"), "«б а» встречается 3 раза"
def most_frequent_pair(words):
counts = {}
for w in words:
for a, b in zip(w, w[1:]):
counts[(a, b)] = counts.get((a, b), 0) + 1
return max(counts, key=counts.get)
Шаг BPE: слияние. Напишите merge_pair(word, pair): замените в списке токенов word каждое вхождение
соседней пары pair = (a, b) на один токен a + b. Проходите слева направо, слитые токены повторно не проверяйте.
Какие тесты проверяют код (1)
assert merge_pair(list("носок"), ("о", "с")) == ["н", "ос", "о", "к"]
assert merge_pair(["н", "ос", "о", "к"], ("н", "ос")) == ["нос", "о", "к"]
assert merge_pair(list("ааа"), ("а", "а")) == ["аа", "а"], "Слева направо: «аа» + «а»"
assert merge_pair(list("кот"), ("а", "б")) == ["к", "о", "т"], "Если пары нет, слово не меняется"
def merge_pair(word, pair):
out, i = [], 0
while i < len(word):
if i + 1 < len(word) and (word[i], word[i + 1]) == pair:
out.append(word[i] + word[i + 1])
i += 2
else:
out.append(word[i])
i += 1
return out
Ближайшие слова. Дана матрица эмбеддингов E формы (V, d) и список слов vocab.
Напишите nearest(word, E, vocab, k): верните список из k слов с наибольшим косинусным сходством
\cos(a, b) = \dfrac{a \cdot b}{\|a\|\,\|b\|} с данным словом, по убыванию сходства, не включая само слово.
Какие тесты проверяют код (2)
vocab = ["кот", "кошка", "пёс", "стол"]
E = np.array([[1.0, 0.1], [2.0, 0.3], [0.7, 0.7], [-1.0, 0.2]])
assert nearest("кот", E, vocab, 2) == ["кошка", "пёс"], "Сравнивайте направления (косинус), а не длины, и не включайте само слово"
assert nearest("стол", E, vocab, 1) == ["пёс"]
vocab = ["a", "b", "c"]
E = np.array([[1.0, 0.0], [10.0, 1.0], [0.5, 0.5]])
assert nearest("a", E, vocab, 2) == ["b", "c"], "Длинный вектор b почти сонаправлен с a — косинус больше"
import numpy as np
def nearest(word, E, vocab, k):
i = vocab.index(word)
En = E / np.linalg.norm(E, axis=1, keepdims=True)
sims = En @ En[i]
order = [j for j in np.argsort(-sims) if j != i]
return [vocab[j] for j in order[:k]]
Войдите, чтобы отмечать пройденные уроки.