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

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

Считаем буквы

Самая простая языковая модель на свете — таблица: какая буква за какой идёт в 41 миллионе букв детских историй. Соберём её и дадим ей что-нибудь написать.

В этой главе

  • превратить текст в числа — ровно такие, какие видит модель
  • посчитать пары букв и получить из счёта условные вероятности
  • вытянуть случайную букву из распределения с помощью кубика с перевесом — и заставить таблицу писать

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

Идея такая. Учитывать всё, что было раньше, трудно, поэтому схитрим и посмотрим только на последнюю букву. Допустим, текст пока заканчивается на «t». Что дальше? Чутьё у вас уже есть: часто «h» (the, that, then), иногда пробел, иногда «o». Такому правилу вас никто не учил — вы просто много читали. Компьютер тоже умеет много читать, а вести счёт у него получается куда лучше нашего.

Буквы превращаются в числа

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

В первых главах курса алфавит Ростка состоит из 97 символов. Это 95 печатных знаков обычной английской клавиатуры (пробел, знаки препинания, цифры, заглавные и строчные латинские буквы), перевод строки и одна особая метка под номером 0. Мы будем рисовать её так: ◆. Она стоит на границе текстов — перед первой буквой каждой истории и после последней. Благодаря ей модель узнаёт, как начинаются истории, и может сказать «на этом всё».

Напечатайте что-нибудь в поле и посмотрите, во что текст превращается на входе модели.

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

Можно было бы дать собственный номер каждому слову, и тогда «cat» был бы для модели одним символом, а не тремя. Но список всех слов огромен и никогда не полон: каждому имени, опечатке и новому слову нужна своя строчка, а слово, которого нет в списке, модель вообще не сможет прочитать. С буквами наоборот: 97 символов покрывают любой английский текст, зато модели приходится больше трудиться, чтобы разглядеть в них слова. Взрослый Росток пользуется компромиссом — частыми кусочками текста, токенами; им посвящена глава 6. А для первых глав буквы идеальны: таблицы получаются достаточно маленькими, чтобы рассмотреть их целиком.

Заметим две вещи. Во-первых, «a» и «A» — разные символы, 67 и 35, и для модели они различаются ровно так же, как «a» и «?». Ничто не подсказывает ей, что одна буква — заглавная форма другой; если эта связь важна, модель должна найти её в данных сама. Во-вторых, сами номера ничего не значат. Из того, что у «a» номер 67, не следует, что она «больше» «A». Это просто имена, как номера кресел в театре: их можно было раздать в любом другом порядке.

Кто за кем ходит

Начнём с совсем маленького примера — со слова banana. Поставим по краям метки, ◆banana◆, и выпишем все пары соседей: ◆b, ba, an, na, an, na, a◆. Семь пар. Теперь сгруппируем их по первой букве:

после…шла…
◆b — один раз
ba — один раз
an — два раза, ◆ — один
na — два раза

Вот и всё: эта табличка — полноценная языковая модель слова banana. Она знает, что слово начинается с «b», что после «n» всегда идёт «a», а после «a» — либо «n», либо конец.

Теперь сделаем то же самое, только всерьёз. Мы взяли 41,3 миллиона букв из историй и диалогов, на которых учится Росток, — всего 39 184 текста — и посчитали все пары соседей. Таблица, где у каждого из 97 символов есть строка и столбец, состоит из 97 × 97 = 9 409 клеток. Вот она. Строка — предыдущая буква, столбец — следующая; чем ярче клетка, тем вероятнее пара.

Нажимайте на клетки, водите по карте пальцем или выбирайте пары снизу. По умолчанию цвет — вероятность внутри строки; «число пар» показывает сырой счёт в логарифмической шкале. «Все 97» добавляет заглавные буквы, цифры, знаки препинания, перевод строки ↵ и метку ◆.

Посидите над картой минуту — в ней полно маленьких историй про английский язык.

  • После q почти всегда идёт u. Из 13 061 буквы «q» за 13 003 следовала «u» — 99,6%. Строка «q» почти пуста: одна яркая клетка и несколько бледных точек.
  • Самая частая пара — «e␣», то есть «e» на конце слова: 1 312 444 раза, 3,2% всех пар. Следом идут «he» и «␣t». Узнаёте «the»?
  • У знаков препинания свои привычки. После запятой в 96,5% случаев стоит пробел. После заглавной T в 82,3% случаев идёт «h»: The, They, There.
  • Четверть всех историй начинается с заглавной O. В строке ◆ у буквы «O» 25,4%: «Once upon a time…», «One day…».
  • Большая часть клеток пуста. 7 051 пара из 9 409 не встретилась ни разу — три четверти таблицы. Даже среди строчных букв и пробела не бывает 136 пар из 729: «jj», «qa», «fq»… Нет и двух пробелов подряд: корпус перед обучением почистили, и двойные пробелы пропали.

Сам подсчёт устроен до неприличия просто. Вот он на чистом Python, на тексте, который можно проверить глазами. Нажмите «Запустить» — а потом замените текст своим.

text = """once upon a time there was a little cat. the cat liked to sit in the sun. one day the cat saw a bird in the tree.""" counts = {} # (previous, next) -> how many times for a, b in zip(text, text[1:]): # every letter with the one right after it counts[(a, b)] = counts.get((a, b), 0) + 1 print(len(text), 'letters,', len(counts), 'different pairs') for (a, b), n in sorted(counts.items(), key=lambda kv: -kv[1])[:6]: print(repr(a + b), n)

zip(text, text[1:]) — изящный приём: он идёт одновременно по тексту и по его копии, сдвинутой на одну букву, так что на каждом шаге мы получаем букву вместе с её правым соседом.

От счёта к вероятностям

Счёт — ещё не модель. Пара «th» встретилась 829 367 раз. Это много? Зависит от того, сколько у неё было шансов, то есть сколько всего было букв «t». А их было 2 702 151. Значит, примерно за 31 «t» из каждых ста шла «h». Такая доля — это и есть вероятность: как часто что-то случилось по сравнению с тем, сколько раз могло случиться.

Нас интересует вероятность следующей буквы $b$ при условии, что предыдущая была $a$. Записывают её как $p(b \mid a)$, а по нашей таблице она считается элементарно:

$$p(b \mid a) = \frac{\text{count}(a, b)}{\text{count}(a)}$$

Сверху — сколько раз встретилась пара $a b$, снизу — сколько раз встретилась $a$ вообще (с чем угодно после неё). Для нашей «t»: $p(\text{h} \mid \text{t}) = 829\,367 / 2\,702\,151 \approx 0{,}307$. Для banana: $p(\text{n} \mid \text{a}) = 2/3$, а $p(\blacklozenge \mid \text{a}) = 1/3$.

Такая вероятность называется условной: мы спрашиваем не о том, насколько вероятна «h» вообще, а о том, насколько она вероятна при условии, что только что была «t». Разница огромная. Среди всех 41,3 миллиона пар на «th» приходится всего 2,0%. Но стоит нам узнать, что перед нами «t», — шанс увидеть «h» подскакивает до 30,7%. Знание о том, что было раньше, — ровно то, ради чего языковая модель существует.

Биграммная модель — это просто таблица. Строка — предыдущая буква, числа в строке — вероятности следующей, и каждая строка в сумме даёт единицу.

(«Биграмма» значит «две буквы»: модель смотрит только на пары. Таблицы по трём буквам называют триграммами, а в общем случае — n-граммами.)

Есть одна загвоздка. Ноль в таблице означает «невозможно». Но так ли невозможна пара «jj»? Слово «hajj» существует — просто не в детских сказках. Модель, которая клянётся, что чего-то не бывает, окажется в большой беде, как только это всё-таки случится. Поэтому настоящая таблица Ростка перед делением добавляет единицу в каждую клетку:

$$p(b \mid a) = \frac{\text{count}(a, b) + 1}{\text{count}(a) + 97}$$

Это называется сглаживанием Лапласа (или «плюс один»). Для частых пар оно ничего не меняет: 829 368 / 2 702 248 — всё те же 30,7%. Зато ни одна пара больше не запрещена наглухо. Почему это так важно, станет ясно в следующей главе: там ноль превратится в бесконечность.

После «q» буква «u» встретилась 13 003 раза, сама «q» — 13 061 раз, а всего в корпусе 41,3 миллиона букв. Чему равна p(u | q)?

Условную вероятность делят на число шансов — сколько раз встретилась «q», а не на размер всего корпуса. И это не ровно единица: 25 раз после «q» стояло двоеточие — в диалогах героев по имени Eriq, Tariq и Tyriq («Tariq: So…»), — и ещё несколько раз другие символы.

Кубик с перевесом

Таблица говорит, насколько вероятна каждая следующая буква. Но чтобы она что-нибудь написала, букву надо выбрать. Можно всегда брать самую вероятную — но тогда после «t» всегда будет «h», после «h» всегда «e», после «e» всегда пробел… и выйдет бесконечное «the the the the». Нам нужно выбирать случайно, но честно: после «t» брать «h» три раза из десяти, пробел — два с половиной раза из десяти, и так далее.

Это кубик с перевесом: у каждой грани свой вес. После «h» у такого кубика 42 грани (столько разных символов вообще встречалось после «h»), и одна грань «e» занимает больше половины. Но как компьютеру бросить такой кубик? Он умеет только одно — выдавать случайное число $u$, равномерно распределённое между 0 и 1.

Хитрость в том, чтобы уложить вероятности друг за другом на отрезке от 0 до 1. Первая буква получает кусок от 0 до $p_1$, вторая — от $p_1$ до $p_1 + p_2$, и так далее: границы кусков — это накопленные суммы вероятностей. Теперь роняем на отрезок стрелку $u$. Она попадает в каждый кусок с шансом, в точности равным его длине, то есть вероятности буквы. Кубик брошен.

Полоска — кубик для одной строки таблицы. Грани идут от самой тяжёлой к самой лёгкой; тонкие полоски у правого края — редкие символы. «×10 000» показывает, как доля каждого исхода подбирается к его вероятности.

Бросьте несколько раз вручную, а потом нажмите «×10 000». Столбики подползут вплотную к чёрточкам: при большом числе бросков частота исхода приближается к его вероятности. Это закон больших чисел — и заодно причина, по которой частоты, посчитанные по 41 миллиону букв, вообще можно считать вероятностями.

На Python весь кубик умещается в несколько строк. В стандартной библиотеке есть и готовый вариант — random.choices, который проходит по тем же накопленным суммам. Эта ячейка пользуется парами, посчитанными в предыдущей: все ячейки страницы живут в одном сеансе Python, как в блокноте Jupyter.

import random def next_probs(a): row = {b: n for (x, b), n in counts.items() if x == a} total = sum(row.values()) # how many times a was followed by anything return {b: n / total for b, n in row.items()} def roll(probs): u = random.random() # a random number from 0 to 1 total = 0.0 for letter, p in probs.items(): # walk the cumulative sums total += p if u < total: return letter return letter # u fell into a rounding crack at the very end print(next_probs('t')) print([roll(next_probs('t')) for _ in range(12)]) print(random.choices('abc', weights=[5, 3, 2], k=12))

Буква за буквой

Теперь у нас есть всё, чтобы писать. Начинаем с метки ◆ и бросаем кубик строки ◆ — получаем первую букву. Потом бросаем кубик строки этой буквы — и так далее. Каждая новая буква выбирает кубик для следующего броска. Когда выпадает ◆, история окончена.

Подсвеченная буква — всё, что модель помнит. Полоска — кубик той строки, которой воспользовались для последней буквы.

Получается лепет — но любопытный лепет. Один из наших запусков начался так: «Frthendd, aced. tre sedsck.. oale, ateyinet thee prerootosal s thu fear frime». У «слов» правдоподобная длина, и между ними пробелы. После точки — пробел и часто заглавная буква. Кавычки открываются и закрываются, мелькают «the» и «he». Смысла, конечно, никакого: модель помнит одну-единственную букву и не знает, где она — посреди слова или в конце фразы. Увидев «h», она понятия не имеет, пришла эта «h» из «th» или из «sh».

И всё же остановитесь на секунду. Никто не объяснял таблице, где ставить пробелы, что предложение начинается с заглавной буквы и что «e» — самая частая буква. Всё это нашлось в подсчёте само.

Вся таблица в numpy

Словари хороши для одной фразы, а для 97 × 97 чисел нужна настоящая таблица — массив numpy. Ячейка ниже загружает те самые числа, что вы видели на карте (22 КБ JSON), превращает их в вероятности с единицей Лапласа и пишет несколько сотен букв.

import json import numpy as np try: from pyodide.http import open_url # in the browser data = json.load(open_url('/llm/data/bigram-counts.json')) except ImportError: # on your own computer data = json.load(open('bigram-counts.json')) ALPHABET = data['alphabet'] # '\n' and the 95 printable ASCII characters N = np.array(data['counts']) # 97 x 97; row and column 0 are the start/end mark P = (N + 1) / (N + 1).sum(1, keepdims=True) # +1 in every cell, then each row sums to 1 t, h = ALPHABET.index('t') + 1, ALPHABET.index('h') + 1 print(N.sum(), 'pairs; p(h | t) =', round(P[t, h], 4), '; row sums:', P.sum(1)[:4]) rng = np.random.default_rng() ids = [0] # start with the start mark while len(ids) < 300: nxt = rng.choice(97, p=P[ids[-1]]) # roll the die of the last letter's row if nxt == 0: break # the mark again: the story is over ids.append(nxt) print(''.join(ALPHABET[i - 1] for i in ids[1:]))

Вся нормировка — строчка с P. Выражение (N + 1).sum(1, keepdims=True) складывает каждую строку и оставляет результат столбцом из 97 чисел; деление таблицы 97 × 97 на такой столбец делит каждую строку на её собственную сумму (в numpy это называется broadcasting, «растягивание»). А rng.choice(97, p=…) — наш кубик с перевесом: внутри он проходит по тем же накопленным суммам.

А вот функция, которая построила настоящую таблицу Ростка, — из кода курса, файл snapshots.py. Она читает 41,3 миллиона букв, одной строкой считает все пары через np.add.at (для каждой позиции прибавляет единицу в клетку «эта буква, следующая буква»), нормирует с единицей Лапласа и сохраняет результат в файл модели, который умеет запускать браузер.

def bigram(args): ids = char_corpus(args.data, args.chars) counts = np.zeros((V, V), dtype=np.int64) np.add.at(counts, (ids[:-1], ids[1:]), 1) probs = (counts + 1) / (counts + 1).sum(1, keepdims=True) logits = np.log(probs).astype(np.float32) state = {'embed.weight': torch.from_numpy(logits)} cfg = {'vocab_size': V, 'context': 1} pack(state, cfg, {'kind': 'mlp', 'name': 'bigram-char', 'chars': int(len(ids))}, f'{args.out}/models/bigram-char.bin', quantize=False) with open(f'{args.out}/data/bigram-counts.json', 'w') as f: json.dump({'alphabet': ALPHABET, 'counts': counts.tolist(), 'chars': int(len(ids))}, f, separators=(',', ':')) nll = -np.log(probs[ids[:-1], ids[1:]]).mean() print(f'bigram: {len(ids)} letters, loss {nll:.4f} nats/letter')

Две строчки в ней пока выглядят загадочно. Таблица хранится в виде логарифмов вероятностей, np.log(probs), а в конце печатается какая-то «loss» — 2,3650 «ната на букву». И то и другое — тема следующей главы: это число и есть ответ на вопрос «насколько модель хороша».

Чего не видно с одной буквы

Наша таблица честная и быстрая, но у неё жёсткий предел: она помнит одну букву. Напрашивается очевидное — считать контексты подлиннее. Будем смотреть на две предыдущие буквы — и таблице понадобится строка на каждую пару букв: 97 × 97 строк по 97 столбцов, 912 673 клетки. Три буквы контекста — уже 88,5 миллиона клеток, больше, чем 41,3 миллиона букв, которые мы посчитали. Восемь букв — около $7{,}6 \cdot 10^{17}$ клеток.

И дело не только в памяти. Почти все такие клетки останутся пустыми: большинство сочетаний из восьми букв не встречается ни в одном корпусе, даже огромном, а про последовательность, которую таблица не видела в точности, ей нечего сказать. Нам нужна модель, которая умеет обобщать: видела «the cat sat» и «the dog sat» — и подозревает, что «the fox sat» тоже годится. Счётом такого не добиться. Нейросети — могут, и именно к ним ведут ближайшие главы.

Почему нельзя просто посчитать таблицу с восемью буквами контекста — так же, как мы посчитали с одной?

Сам подсчёт как раз быстрый: окон столько же, сколько букв, и на каждое уходит одно прибавление. Память — проблема настоящая, но даже бесконечная память не спасёт: при $7{,}6 \cdot 10^{17}$ клетках и всего лишь миллиардах букв текста почти все клетки останутся пустыми. Нужна модель, которая переносит выученное с похожих контекстов, а не ищет точное совпадение.

Остался открытым ещё один вопрос. Мы посмотрели на лепет и сказали: «вроде похоже на английский». Это впечатление, а не измерение. Как выразить качество модели числом — чтобы сравнивать две модели или видеть, как модель становится лучше во время обучения? Об этом следующая глава.

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

Росток впервые заговорил — пока лепетом. Сейчас он — таблица из этой главы, 9 409 вероятностей, и работает прямо у вас в браузере. Каждую букву он выбирает, бросая новый кубик с перевесом. Начало можно задать любое, но важна только его последняя буква: «Once upon a time» и «I like» стартуют с одного и того же кубика — кубика «e». Попробуйте заготовку «Q»: в девяти случаях из десяти следом придёт «u». В следующей главе мы точно измерим, насколько эта модель плоха, — и получим число, которое обучение будет толкать вниз.

Главы

  1. 0 Знакомство
  2. 1 Считаем буквы
    1. Буквы превращаются в числа
    2. Кто за кем ходит
    3. От счёта к вероятностям
    4. Кубик с перевесом
    5. Буква за буквой
    6. Вся таблица в numpy
    7. Чего не видно с одной буквы
    8. Росток сейчас
  3. 2 Мера удивления
  4. 3 Градиентный спуск
  5. 4 Обратное распространение
  6. 5 Эмбеддинги
  7. 6 Токены
  8. 7 Внимание
  9. 8 Трансформер
  10. 9 Корпус
  11. 10 Обучение
  12. 11 Выборка
  13. 12 Разговор
  14. 13 LoRA
  15. 14 Что дальше