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

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

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

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

В этой главе

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

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

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

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

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

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

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

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

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

Проверьте, что сеть действительно видит только координаты. В окошке прогноза — контекст «␣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, как на запятую. Всё, что сеть узнает про a, ничего не скажет ей про A, — каждую букву придётся изучать с нуля. И вектор выходит длинным и пустым: 96 нулей ради одной единицы.

Эмбеддинг — третий путь. Каждому символу выдаём короткий вектор из $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 буквах видно, как оно начинается: проверочная ошибка какое-то время снижается, находит минимум и медленно ползёт вверх.

Отсюда два практических правила. Первое: судить о модели только по проверочной ошибке. Второе: лучшее лекарство от переобучения — больше данных. Росток при 17 миллионах параметров прочитал за обучение 330 миллионов токенов — меньше четверти корпуса, так что почти всё прочитанное он видел впервые и зубрить ему было нечего.

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

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

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

Теперь у нас есть всё, чтобы сделать модель сильнее. Самое очевидное — дать ей видеть больше. Биграмма из главы 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 Что дальше