← Росток Своя LLM с нуля Словарик Код EN

Глава 5 из 17 30 мин

Буквы как точки

Заменим номер символа обучаемым вектором. Посмотрим на карту таких векторов, разберём нужную арифметику и проследим обучение по двум кривым ошибки.

В этой главе

  • объяснить, чем эмбеддинг лучше номера буквы и one-hot-вектора
  • считать сходство векторов через скалярное произведение и косинус, а умножение матриц видеть как пачку скалярных произведений
  • прочитать настоящий цикл обучения на PyTorch и отличить обучение от переобучения

Прежде чем предсказать четвёртый символ, наша сеть заменяет каждый из трёх предыдущих вектором из шестнадцати чисел. Почему именно такие числа? Их значения подбирались во время обучения вместе с остальными параметрами. Посмотрим, как устроено это представление и какие свойства текста в нём можно заметить.

Шестнадцатимерный вектор нельзя напрямую показать точкой на плоскости без потери информации. Поэтому для этого опыта мы обучили отдельную сеть с более узкими эмбеддингами — всего по два числа на символ. Их можно использовать как координаты. Перед нами будут сами обученные векторы этой сети, а не проекция шестнадцатимерных векторов из прошлой главы.

Карта, которую нарисовала сеть

Ниже — все 97 символов, которые знают наши буквенные модели: строчные и заглавные буквы, цифры, знаки препинания, пробел, перевод строки и метка начала текста. Место каждого на карте — это его два числа после обучения. Раскраску по группам добавили мы; сеть о группах ничего не знала.

Настоящие эмбеддинги сети «3 буквы → следующая» с двумя числами на букву (13 603 параметра). Прогноз рядом с картой считается прямо здесь, её же весами.

Присмотритесь: порядок виден сразу. Гласные a, e, i, o, u собрались в одном углу, и рядом с ними их заглавные A, I, U, O, E. Точка, восклицательный и вопросительный знаки, двоеточие и точка с запятой держатся вместе на противоположном краю. Пробел, перевод строки и метка начала текста сбились в кучку: для сети это одно и то же событие — «здесь кончилось слово». Многие заглавные стоят рядом со своей строчной парой: t и T, h и H, n и N.

А редкие значки вроде ^, {, ~ и | толпятся в середине без всякого порядка. Мы проверили: они почти не сдвинулись с тех случайных мест, где оказались до обучения. В текстах эти значки попадаются редко, и сеть их просто не трогала.

Мы не сообщали сети, какие буквы гласные. Группы возникают потому, что символы часто встречаются в сходных контекстах и требуют похожих продолжений. Близкие векторы позволяют следующим слоям обрабатывать такие случаи сходным образом. Но в функции потерь нет отдельного требования «сдвинуть все гласные вместе»: расположение складывается при совместном обучении эмбеддингов и остальных весов. В другом запуске карта может повернуться или выглядеть иначе.

Проверьте, что сеть действительно видит только координаты. В окошке прогноза — контекст «␣th»: пробел, t, h. После него сеть с вероятностью около 76% ждёт e — дальше, скорее всего, будет the. Теперь перетащите букву h в угол к гласным, поближе к a. Прогноз перевернётся: сеть начнёт ждать согласные m и l, как после «␣ta». Вы изменили два обученных параметра эмбеддинга; остальные веса остались прежними. А если утащить h к точке, сеть решит, что предложение кончилось, и будет ждать перевод строки.

Эмбеддинг — обучаемый вектор символа или токена. Его координаты подбираются так, чтобы модель лучше решала свою задачу; близость векторов иногда помогает увидеть, какие случаи она обрабатывает сходным образом.

Номер, выключатели и точка

Разберёмся, зачем вообще понадобились эти координаты. В главе 1 каждый символ получил номер: пробел — 2, a — 67, b — 68, z — 92. Почему бы не подать в сеть сам номер?

Плохая идея. Сеть умножает входы на веса и складывает, то есть обращается с ними как с величинами. Для неё b окажется «чуть больше» a, а тильда с номером 96 — «огромной» буквой. Номера мы раздали по порядку таблицы ASCII, и никакого смысла в их величине нет.

Следующая мысль — one-hot, «один горячий». Заводим ряд из 97 выключателей, по одному на символ, и для буквы a включаем только выключатель номер 67. Получается вектор из 97 чисел: одна единица, остальные нули. Никаких ложных «больше-меньше» — все символы равноправны.

Расстояния между разными one-hot-векторами одинаковы: такая запись сама по себе не выражает сходство «a» и «A». Однако сеть, получающая one-hot-векторы, всё равно может научиться общим закономерностям. Если умножить их на обучаемую матрицу, мы как раз получим эмбеддинги. Разница в том, что плотный короткий вектор удобнее хранить и передавать дальше, чем 97 чисел с единственной единицей.

Эмбеддинг — третий путь. Каждому символу выдаём короткий вектор из $d$ обычных чисел — у нашей карты $d = 2$, у сети из прошлой главы 16, у Ростка 384 — и позволяем обучению эти числа двигать. Все такие векторы хранятся в одной таблице $E$ размером $97 \times d$: строка номер 67 — это вектор буквы a. Чтобы превратить букву в вектор, достаточно взять нужную строку.

Насколько похожи два вектора

На карте можно измерять расстояния между точками, а для векторов часто полезно сравнивать направления. Начнём со скалярного произведения: перемножим соответствующие координаты и сложим результаты. Оно зависит и от угла между векторами, и от их длин, поэтому само по себе ещё не является чистой мерой сходства направлений:

$$a \cdot b = a_1 b_1 + a_2 b_2 + \dots + a_d b_d.$$

Для двух измерений это две пары множителей. Поиграйте со стрелками и посмотрите, когда число получается большим, когда обращается в ноль, а когда уходит в минус.

Толстая полоса вдоль стрелки a — тень стрелки b. Скалярное произведение равно длине этой тени, умноженной на длину a. Зелёная тень — число положительное, красная — отрицательное.

Закономерность такая: стрелки смотрят в одну сторону — произведение большое и положительное; под прямым углом — ноль; в разные стороны — отрицательное. Но попробуйте кнопку «b вдвое длиннее»: направление то же, а число выросло вдвое («b вдвое короче» так же вдвое его уменьшит). Скалярное произведение чувствует и направление, и длину.

Чтобы сравнить только направления двух ненулевых векторов, разделим скалярное произведение на произведение их длин. Получится косинусное сходство от −1 до 1. Для нулевого вектора направление не определено, и этой формулой пользоваться нельзя:

$$\cos\theta = \frac{a \cdot b}{|a|\,|b|}, \qquad |a| = \sqrt{a \cdot a} = \sqrt{a_1^2 + a_2^2 + \dots}$$

Этот приём мы будем встречать постоянно. Нейрон из прошлой главы — это скалярное произведение входа на веса плюс сдвиг. Внимание в главе 7 решает, какие слова важны, через скалярные произведения. И даже последний шаг Ростка — вероятность каждого из 8 192 токенов — начинается с 8 192 скалярных произведений.

Умножение матриц — это много скалярных произведений

Нейрон берёт вход и считает одно скалярное произведение. Слой из 256 нейронов считает 256 скалярных произведений с одним и тем же входом. А сеть при обучении обрабатывает не одно окно из трёх букв, а сотни окон сразу. Сотни входов на сотни нейронов — это десятки тысяч скалярных произведений, и записывать их поодиночке никто не станет. Для этого придумана одна операция — умножение матриц.

Матрица — прямоугольная таблица чисел. Произведение $C = A B$ устроено так: клетка $c_{ij}$ равна скалярному произведению строки $i$ матрицы $A$ на столбец $j$ матрицы $B$:

$$c_{ij} = \sum_{k} a_{ik}\, b_{kj}.$$

Отсюда правило размеров: длина строки $A$ должна совпадать с высотой столбца $B$. Матрица $n \times k$ умножается на $k \times m$, и получается $n \times m$. Посмотрите, как это выглядит:

Матрица $A$ слева, $B$ сверху, произведение — на их пересечении. Клетка результата стоит ровно там, где встречаются её строка и её столбец.

Теперь переключите виджет на «One-hot × таблица». Слева — слово из трёх букв, записанное one-hot-строками, сверху — маленькая таблица эмбеддингов (настоящие координаты с карты). Посчитайте клетки: в каждом скалярном произведении работает только одно слагаемое, то, что стоит под единицей, а остальные умножаются на ноль. В результате получаются просто строки таблицы.

Умножение one-hot-вектора на таблицу просто выбирает одну строку. Поэтому в коде используется обращение по индексу: E[id]. Оно не требует создавать большой вектор нулей и выполнять лишние умножения. Математический результат тот же; выигрыш по времени зависит от размеров и реализации. Проверим равенство на NumPy:

import numpy as np # four letters and their coordinates from the map above letters = ['a', 'e', 't', 'h'] E = np.array([[ 3.70, -2.71], [ 0.78, -1.81], [-1.79, -2.05], [-0.73, 1.24]]) ids = [2, 3, 1] # "the" one_hot = np.eye(4)[ids] # three rows with a single 1 each print(one_hot) print(one_hot @ E) # multiply by the table... print(E[ids]) # ...or just take the rows: the same numbers def cosine(u, v): return u @ v / (np.linalg.norm(u) * np.linalg.norm(v)) print(' ' + ' '.join(letters)) for i, x in enumerate(letters): print(x, ' '.join(f'{cosine(E[i], E[j]):+.2f}' for j in range(4)))

Значок @ в numpy и PyTorch означает умножение матриц. В таблице косинусов видно то же, что на карте: у a и e сходство +0,86, а h с обеими гласными почти противоположна.

Тензоры и пачки

Осталось ещё одно слово — тензор. Это просто таблица чисел с любым количеством измерений: число — ноль измерений, вектор — одно, матрица — два. Пачка из $B$ окон по 3 буквы — это тензор номеров формы $(B, 3)$. Когда мы достаём эмбеддинги, у каждой буквы появляется 16 чисел, и форма становится $(B, 3, 16)$. Три вектора каждого окна склеиваем в один длинный — $(B, 48)$ — и умножаем на матрицу весов скрытого слоя. Проследим за формами на всём пути от номеров до логитов:

import numpy as np rng = np.random.default_rng(0) B, T, d, H, V = 4, 3, 16, 256, 97 # batch, window, embedding, hidden, vocabulary E = rng.normal(size=(V, d)) # the embedding table: a row per symbol W1 = rng.normal(size=(H, T * d)) * 0.1 # nn.Linear stores weights as (out, in) W2 = rng.normal(size=(V, H)) * 0.1 x = rng.integers(0, V, size=(B, T)) # 4 windows of 3 letter ids e = E[x] # look-up: (4, 3, 16) flat = e.reshape(B, T * d) # glue each window's vectors: (4, 48) hid = np.tanh(flat @ W1.T) # 4 × 256 dot products in one go logits = hid @ W2.T # (4, 97): a score for every next letter for name, a in [('x', x), ('e', e), ('flat', flat), ('hid', hid), ('logits', logits)]: print(f'{name:7s}', a.shape)

Одна строчка flat @ W1.T — это $4 \times 256 = 1\,024$ скалярных произведения по 48 слагаемых. Видеокарты и процессоры умеют делать такие умножения невероятно быстро, и вся современная нейросетевая техника держится на том, что почти любую работу удаётся записать через умножение матриц.

Что получится, если one-hot-строку буквы «t» умножить на таблицу эмбеддингов?

В one-hot-строке одна единица, остальные нули. В каждом скалярном произведении выживает одно слагаемое, и в итоге получается ровно строка «t». Поэтому умножение заменяют простым E[id].

Та же сеть на PyTorch

До сих пор мы писали всё руками. Настоящие модели курса написаны на PyTorch — библиотеке, которая умеет три вещи: хранить тензоры, быстро их перемножать (в том числе на видеокарте) и самостоятельно считать градиенты — как наш Value из прошлой главы, только для тензоров любого размера. Вот класс, из которого сделаны все буквенные сети курса, — от карты в начале главы до модели в конце. Он взят из файла snapshots.py без изменений:

class MLP(nn.Module): def __init__(self, vocab, context, d_embed, hidden): super().__init__() self.context = context self.embed = nn.Embedding(vocab, d_embed) dims = [context * d_embed] + hidden self.hidden = nn.ModuleList(nn.Linear(a, b) for a, b in zip(dims, dims[1:])) self.out = nn.Linear(dims[-1], vocab) def forward(self, x): # x: (batch, context) h = self.embed(x).flatten(1) for layer in self.hidden: h = torch.tanh(layer(h)) return self.out(h)

Три детали, и все они нам уже знакомы:

  • nn.Embedding(vocab, d_embed) — таблица $E$ размером 97 × d_embed. Вызов self.embed(x) достаёт строки по номерам — тот самый E[x]. Градиент при обучении попадает только в строки тех букв, которые были в пачке.
  • nn.Linear(a, b) — слой из b нейронов с a входами каждый: матрица весов $b \times a$ и вектор сдвигов. Вызов слоя — это x @ W.T + bias, пачка скалярных произведений.
  • flatten(1) склеивает векторы букв окна в один: форма $(B, 3, 16)$ превращается в $(B, 48)$.

Карта в начале главы — это MLP(97, 3, 2, [128]): три буквы по два числа, скрытый слой на 128 нейронов. Сеть из прошлой главы — MLP(97, 3, 16, [256]). Модель, которую вы увидите в конце, — MLP(97, 8, 24, [512, 512]): восемь букв по 24 числа и два скрытых слоя.

Учимся пачками

Как такую сеть обучить? Идея та же, что в главе 3: посчитать ошибку, найти градиент, сделать шаг вниз. Вопрос в том, на скольких примерах считать ошибку перед каждым шагом.

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

Вот настоящий цикл обучения всех буквенных моделей курса, тоже из snapshots.py:

def train_mlp(name, ids, vocab, context, d_embed, hidden, args, lr=3e-3, steps=None, batch=512): torch.manual_seed(0) data = windows(ids, context) n_val = len(data) // 50 val, tr = data[:n_val], data[n_val:] model = MLP(vocab, context, d_embed, hidden) opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01) steps = steps or args.steps t0 = time.time() log = [] for step in range(steps + 1): if step % 500 == 0 or step == steps: with torch.no_grad(): idx = torch.randint(0, len(val), (8192,), generator=torch.Generator().manual_seed(0)) vb = val[idx] vl = F.cross_entropy(model(vb[:, :-1]), vb[:, -1]).item() log.append({'step': step, 'val': round(vl, 4)}) print(f'{name} step {step} val {vl:.4f} ({time.time() - t0:.0f}s)', flush=True) if step == steps: break b = tr[torch.randint(0, len(tr), (batch,))] loss = F.cross_entropy(model(b[:, :-1]), b[:, -1]) opt.zero_grad(set_to_none=True) loss.backward() opt.step() for g in opt.param_groups: g['lr'] = lr * (0.1 + 0.9 * 0.5 * (1 + math.cos(math.pi * step / steps))) return model, log

Прочитаем его сверху вниз.

  1. windows нарезает текст на окна: нужное число букв контекста плюс следующая буква — правильный ответ. Первые 2% окон (len(data) // 50) откладываются в сторону — о них ниже.
  2. Каждый шаг берёт 512 случайных окон: b[:, :-1] — контекст, b[:, -1] — ответы.
  3. F.cross_entropy — наша старая знакомая из главы 2: softmax по логитам, минус логарифм вероятности правильного ответа, среднее по пачке. Одна функция вместо трёх строк.
  4. loss.backward() — обратный проход из главы 4: PyTorch сам находит градиент по каждому из сотен тысяч параметров, включая строки таблицы эмбеддингов.
  5. opt.step() — шаг вниз. Вместо простого градиентного спуска здесь AdamW, более хитрый способ выбирать шаг для каждого параметра; его разберём в главе 10. Последние две строки плавно уменьшают скорость обучения к концу.

Модель из конца главы прошла 20 000 таких шагов, то есть увидела около 10 миллионов окон. На ноутбуке с M4 Pro это заняло около трёх минут.

Экзамен на незнакомом тексте

Зачем откладывать 2% окон? Затем, что ошибка на обучающем тексте врёт. Сеть может научиться хорошо предсказывать именно те окна, которые видела, — просто запомнив их, — и при этом ничего не понять о языке. Проверить можно только одним способом: дать ей текст, которого она никогда не видела, и посмотреть на ошибку там. Отложенный текст называют проверочным (validation), остальной — обучающим.

Лучше всего увидеть это вживую. Ниже — крошечная сеть с двумя числами на букву, которая учится прямо у вас в браузере на кусочке настоящих историй из нашего корпуса. Рядом с её картой букв — две кривые: ошибка на обучающем тексте и на проверочном. Начните с 3 000 букв, потом попробуйте 400 и весь текст.

Сеть: 3 буквы по 2 числа → 48 нейронов → 28 символов, 1 764 параметра, оптимизатор Adam, пачки по 32 окна. Проверочный текст — 5 000 букв, которых нет среди обучающих. Всё считается на вашем устройстве.

На всём тексте обе кривые идут вниз вместе, а гласные одна за другой отползают от кучки согласных — карта складывается на глазах. На 400 буквах происходит другое. Обучающая ошибка падает гораздо ниже, чем на большом тексте, а проверочная сначала чуть снижается, потом разворачивается и уходит вверх — выше, чем если бы сеть гадала наугад. У сети 1 764 параметра, а примеров меньше 400: ей проще запомнить каждое окно наизусть, чем искать правила. Это называется переобучением (overfitting). На 3 000 буквах видно, как оно начинается: проверочная ошибка какое-то время снижается, находит минимум и медленно ползёт вверх.

При обучении следите за обеими кривыми. Обучающая показывает, удаётся ли оптимизация, а проверочная — переносится ли улучшение на отложенные данные. Если разрыв растёт, могут помочь более разнообразные данные, регуляризация, меньшая модель или ранняя остановка. Росток обработал около 330 миллионов токенов из корпуса размером 1,43 миллиарда. Это уменьшает число повторных встреч по сравнению с многократным проходом по небольшому набору, но не исключает запоминания или повторов при случайном выборе окон. После подбора настроек полезна и отдельная тестовая выборка, которая не участвовала в выборе модели.

Ошибка на обучающем тексте падает, а на проверочном растёт. Что происходит?

Это переобучение. Модель всё лучше помнит то, что видела, и всё хуже работает на новом. Помогают больше данных, модель поменьше или ранняя остановка там, где проверочная ошибка была минимальной.

Шире окно — умнее модель

Теперь у нас есть всё, чтобы сделать модель сильнее. Самое очевидное — дать ей видеть больше. Биграмма из главы 1 смотрела на одну букву, сеть из прошлой главы — на три. Возьмём восемь, дадим каждой букве 24 числа и добавим второй скрытый слой. Все эти модели мы проверили на одном и том же тексте, которого ни одна из них не видела при обучении, — 4,9 миллиона букв:

МодельВидит буквПараметровНаты на букву
наугад из 97 символов004,575
биграмма, глава 119 4092,364
карта, 2 числа на букву313 6031,874
сеть, глава 4339 0251,479
сеть, эта глава8413 5611,103

Каждая строчка — заметный шаг. Даже крошечная карта с двумя числами на букву уже сильно обходит биграмму: ей помогают три буквы контекста и то, что похожие буквы делят опыт. Больше чисел на букву и больше нейронов дают 1,479. Восемь букв — 1,103. Для сравнения: перплексия (глава 2) падает с $e^{2{,}364} \approx 10{,}6$ «равновероятных вариантов» на букву у биграммы до $e^{1{,}103} \approx 3{,}0$.

Посмотрите на разницу своими глазами: три модели пишут одновременно, с одного начала и с одинаковой температурой.

Полоски над каждой колонкой — окно модели: сколько последних букв она видит. Внизу — удивление каждой модели на любой вашей фразе, посчитанное прямо сейчас.

Биграмма лепит обрывки, похожие на слова. Сеть на трёх буквах пишет уже много настоящих коротких слов. Восьмибуквенная складывает целые обороты из историй — «there was a little», «One day,» или «felt a», — но смысл всё равно рвётся через каждые несколько слов. Причина понятна: восемь букв — это полтора-два слова. Всего, что было раньше, модель не видит вообще.

Почему бы не взять окно в 500 букв?

Первый слой такой сети получает на вход все векторы окна подряд. У нашей модели это 8 × 24 = 192 числа, и первый слой хранит 192 × 512 = 98 304 веса. При окне в 500 букв весов в первом слое стало бы в 62 раза больше, и для их надёжного обучения понадобились бы подходящие данные.

Но хуже другое. Каждое место в окне обрабатывается своими весами: буква «t» на первой позиции и та же «t» на пятой проходят через разные числа. Всё, что сеть выучила про «the» в начале окна, приходится учить заново для каждого сдвига. Это расточительно, и из этого тупика есть два выхода. Первый — сделать кусочки крупнее, чтобы в то же окно помещалось больше текста: это токены, следующая глава. Второй — механизм, который обращается с любой позицией одинаково и сам решает, куда смотреть: внимание, глава 7.

Эмбеддинги, скалярные произведения и умножение матриц — три кирпича, из которых сложен весь Росток. Всё остальное — способы решить, какие числа с какими перемножать.

Росток сейчас

Росток пока читает по буквам, но видит сразу восемь и знает, какие буквы похожи друг на друга. Он пишет почти настоящие английские фразы, хотя через пару слов забывает, о чём начинал. Удивление упало с 2,364 ната на букву у биграммы до 1,103. В следующей главе мы перестанем кормить его буквами: в окне останется восемь ячеек, но в каждую ляжет целый кусок слова.

Главы

  1. 0 Знакомство
  2. 1 Считаем буквы
  3. 2 Мера удивления
  4. 3 Градиентный спуск
  5. 4 Обратное распространение
  6. 5 Эмбеддинги
    1. Карта, которую нарисовала сеть
    2. Номер, выключатели и точка
    3. Насколько похожи два вектора
    4. Умножение матриц — это много скалярных произведений
    5. Та же сеть на PyTorch
    6. Учимся пачками
    7. Экзамен на незнакомом тексте
    8. Шире окно — умнее модель
    9. Росток сейчас
  7. 6 Токены
  8. 7 Внимание
  9. 8 Трансформер
  10. 9 Корпус
  11. 10 Обучение
  12. 11 Выборка
  13. 12 Разговор
  14. 13 LoRA
  15. 14 Больше данных
  16. 15 Модель побольше
  17. 16 Думать и Python
  18. 17 Что дальше