Tokenization и Embeddings

⏱ 45 мин · 🧩 заданий: 7

Текст — это не числа

Нейросеть умеет только умножать матрицы и применять функции к числам. Чтобы она работала с текстом, его нужно превратить в числа. Путь такой:

«кот спит» ──► токены ──► номера ──► векторы ──► нейросеть ["кот", "спит"] [17, 42] [[0.1, −0.3, …], [0.8, 0.2, …]] токенизация словарь эмбеддинги

Токен — единица текста, с которой работает модель. Словарь — список всех токенов, номер токена — его позиция в словаре. Сначала разберёмся, на что резать текст.

Токенизация: символы, слова, части слов

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) строит словарь подслов жадно:

  1. Начинаем со словаря из отдельных символов: каждое слово — список букв.
  2. Находим самую частую пару соседних токенов во всём корпусе.
  3. Сливаем её в новый токен и добавляем его в словарь.
  4. Повторяем, пока словарь не достигнет нужного размера.
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:

e_i^\top E = E_{i,:}
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

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Заполнить таблицу

Какой способ токенизации обладает каждым свойством?

символысловаBPE
Самый маленький словарь
Самые длинные последовательности
Незнакомое слово превращается в [UNK]
Частые слова — один токен, редкие разбиваются на части
Используется в GPT, BERT и других современных моделях
Отметьте ячейку — «+», пустая — «−».
Задание 2 Один вариант ответа

Чему равно скалярное произведение one-hot векторов двух разных слов?

Задание 3 Ввод ответа

Словарь — 50 000 токенов, размер эмбеддинга — 768. Сколько обучаемых параметров в таблице эмбеддингов?

Задание 4 Один вариант ответа

Матрица эмбеддингов E имеет форму (V, d), а e_i — one-hot вектор токена i. Чему равно e_i^\top E?

Задание 5 Программа на Python

Шаг 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 раза"

Задание 6 Программа на Python

Шаг 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("кот"), ("а", "б")) == ["к", "о", "т"], "Если пары нет, слово не меняется"

Задание 7 Программа на Python

Ближайшие слова. Дана матрица эмбеддингов 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 — косинус больше"

Войдите, чтобы отмечать пройденные уроки.