Биграммная модель на русских именах
Строим первую настоящую языковую модель — считаем пары букв в 242 русских именах, генерируем новые имена, оцениваем модель через NLL и превращаем таблицу счётчиков в однослойную нейросеть.
Зачем это нужно
Заголовок раздела «Зачем это нужно»В прошлом уроке мы выяснили, что языковой модели достаточно уметь предсказывать следующий токен. Сегодня построим такую модель целиком — от данных до генерации — на самой простой задаче: придумывать новые русские имена. Токеном будет буква, а модель будет смотреть только на одну предыдущую букву. Это биграммнаябиграммаEN bigramПара соседних токенов. Биграммная модель оценивает P(x_t | x_{t−1}) — таблица V × V. модель.
Модель примитивная, зато в ней есть всё, что потом понадобится для GPT: словарь и спецтокен, распределение следующего токена, сэмплированиесэмплирование (выборка)EN samplingСлучайный выбор токена пропорционально вероятностям модели., оценка через отрицательное лог-правдоподобиеотрицательное лог-правдоподобие (NLL)EN negative log-likelihood (NLL)−log P(данные | модель), обычно усреднённое по токенам. Для языковой модели совпадает с cross-entropy. и, наконец, та же модель в виде нейросети, обученной градиентным спуском. Урок повторяет первую лекцию makemore Андрея Карпатого, только на русских именах.
Весь код ниже — один скрипт на PyTorch, разбитый на шаги. Запускайте его локально, шаг за шагом.
Данные: 242 русских имени
Заголовок раздела «Данные: 242 русских имени»Возьмём 242 имени: полные мужские и женские (александр, мария), уменьшительные (саша, ксюша, стёпа) и несколько
старинных (добрыня, пелагея, ефросинья). Все в нижнем регистре, буква «ё» сохранена. Сохраните список в файл names.py:
NAMES = """александр алексей альберт анатолий андрей антон аркадий арсений артём артур богдан борис вадимвалентин валерий василий вениамин виктор виталий владимир владислав всеволод вячеслав гавриилгеннадий георгий герман глеб григорий давид даниил демид денис дмитрий добрыня евгений егоремельян ефим захар иван игнат игорь илья иннокентий ипполит кирилл клим константин кузьмалаврентий лев леонид лука макар максим марк матвей мирон михаил назар никита никифор николайолег остап павел платон прохор пётр родион роман ростислав руслан савва савелий святослав семёнсергей станислав степан тарас тимофей тимур тихон трофим фёдор филипп эдуард эмиль юрий яков янярослав ада агата александра алёна алина алиса алла анастасия ангелина анжелика анна антонинаарина валентина валерия варвара василиса вера вероника виктория галина дарья диана ева евгенияекатерина елена елизавета есения жанна зинаида злата зоя инна ирина камила карина кира клавдиякристина ксения лада лариса лидия лилия любовь людмила майя маргарита марина мария миланамирослава надежда наталья нина нонна оксана олеся ольга полина раиса регина римма роза сабинасветлана серафима снежана софия стефания таисия тамара татьяна ульяна эвелина элина эмилияюлиана юлия яна ярослава саша маша даша миша коля петя ваня катя лена оля таня наташа женя димасерёжа лёша андрюша настя юля аня вова витя гриша паша костя ксюша люба надя галя зина тома сонялиза поля варя федя гоша стёпа тёма слава толя валя рома лёва боря глашафаина эльвира ника мила аксинья дарина марфа пелагея ефросинья прасковья евдокия устиньялукерья фрол фома гордей елисей мстислав любомир велимир""".split()Для сравнения: в оригинальном makemore 32 тысячи английских имён. У нас данных в сто с лишним раз меньше, и это скажется на качестве. Зато все эффекты будут видны особенно ярко.
Шаг 1. Словарь и счётчики биграмм
Заголовок раздела «Шаг 1. Словарь и счётчики биграмм»Токены — буквы. Добавим служебный токен ., который означает и начало, и конец имени. Имя «анна» превращается
в последовательность . а н н а . и даёт пять биграмм: .а, ан, нн, на, а.. Биграмма .а учит модель,
что имена часто начинаются на «а», а а. — что на «а» они часто заканчиваются. Без служебного токена модель не знала бы,
где остановиться.
Счётчики сложим в матрицу размера : — сколько раз символ шёл сразу после символа .
import mathimport randomimport torchimport torch.nn.functional as Ffrom names import NAMES
chars = sorted(set("".join(NAMES))) # все разные буквы из всех имён, по алфавитуstoi = {ch: i + 1 for i, ch in enumerate(chars)} # string-to-index: буква → номер (с 1)stoi["."] = 0 # служебный токен начала/конца имениitos = {i: ch for ch, i in stoi.items()} # index-to-string: обратный словарь номер → букваV = len(stoi) # размер словаряprint("V =", V)
N = torch.zeros((V, V), dtype=torch.int32) # таблица счётчиков V×V, пока нулиfor name in NAMES: seq = ["."] + list(name) + ["."] # «анна» → [".", "а", "н", "н", "а", "."] for a, b in zip(seq, seq[1:]): # пары соседей: (".", "а"), ("а", "н"), … N[stoi[a], stoi[b]] += 1 # строка — текущая буква, столбец — следующаяprint("Всего биграмм:", N.sum().item())
top = torch.topk(N.flatten(), 5) # 5 самых больших чисел в «вытянутой в строку» таблице# flatten нумерует клетки подряд: клетка (i, j) получает номер i * V + j.# Поэтому номер k превращается обратно в (строку, столбец) как (k // V, k % V).print("Самые частые:", ", ".join(f"«{itos[i // V]}{itos[i % V]}» {c}" for c, i in zip(top.values.tolist(), top.indices.tolist())))V = 31Всего биграмм: 1627Самые частые: «а.» 97, «я.» 50, «на» 47, «ан» 31, «ин» 29В именах встретились 30 букв (нет «ц», «щ» и «ъ»), плюс служебный токен — . Самая частая биграмма а.:
97 имён из 242 заканчиваются на «а» — почти все женские и уменьшительные. На втором месте я. (мария, ксения, катя).
Модель ещё ничего не «выучила», а таблица уже рассказывает о русском языке.
Посмотрите на всю таблицу глазами — так делает Карпатый в makemore. Нужен matplotlib (модуль 0.3):
import matplotlib.pyplot as plt
plt.figure(figsize=(14, 14))plt.imshow(N, cmap="Blues") # чем темнее клетка, тем чаще биграммаfor i in range(V): for j in range(V): plt.text(j, i, itos[i] + itos[j], ha="center", va="bottom", color="gray", fontsize=6) plt.text(j, i, N[i, j].item(), ha="center", va="top", color="gray", fontsize=6)plt.axis("off")plt.show()print("Ненулевых клеток:", (N > 0).sum().item(), "из", V * V)Ненулевых клеток: 281 из 961Самые тёмные клетки — а. и я. в столбце «.» (окончания имён), а верхняя строка «.» показывает первые буквы.
А главное — таблица почти пустая: 70% клеток нули. Из 961 возможной пары букв в 242 именах встретилась только 281. Это та самая
разреженность из прошлого урока, и в шаге 5 она ударит по нам всерьёз.
Шаг 2. Из счётчиков — вероятности
Заголовок раздела «Шаг 2. Из счётчиков — вероятности»Строка — это сколько раз какие буквы шли после буквы . Чтобы получить распределение , делим каждую строку на её сумму. Это та же оценка максимального правдоподобия, что в модуле 1.3: частота — лучшая оценка вероятности.
P = N.float() # целые счётчики → дробные числа, иначе деление будет целочисленнымP = P / P.sum(dim=1, keepdim=True) # каждая строка — распределение P(следующий | текущий)print("Сумма строки «а»:", P[stoi["а"]].sum().item())print(f"P(. | а) = {P[stoi['а'], 0]:.3f} P(н | а) = {P[stoi['а'], stoi['н']]:.3f}")first = torch.topk(P[0], 4) # строка 0 («.») — с каких букв начинаются именаprint("Первая буква:", ", ".join(f"{itos[i]} {p:.3f}" for p, i in zip(first.values.tolist(), first.indices.tolist())))Сумма строки «а»: 1.0P(. | а) = 0.404 P(н | а) = 0.129Первая буква: а 0.107, в 0.099, с 0.074, м 0.070После «а» имя заканчивается в 40% случаев, а продолжается на «н» — в 13%. Строка 0 — распределение первых букв: чаще всего имена начинаются на «а», «в», «с», «м».
Шаг 3. Генерация имён
Заголовок раздела «Шаг 3. Генерация имён»Генерация — авторегрессия из прошлого урока в миниатюре. Стартуем с . (номер 0), берём строку и выбираем
случайную букву с этими вероятностями — это сэмплирование из категориального распределениякатегориальное распределениеEN categorical distributionРаспределение на K исходах с вероятностями p₁…p_K. Выход softmax в LLM — категориальное распределение на словаре. Не путать с мультиномиальным (multinomial) — это число исходов каждого типа за n бросков. (модуль 1.3). Затем берём строку выбранной буквы, и так далее, пока снова не выпадет ..
g = torch.Generator().manual_seed(42) # генератор со своим seed — для воспроизводимостиsamples = []for _ in range(10): # 10 имён out, ix = [], 0 # начинаем со служебного токена «.» (номер 0) while True: ix = torch.multinomial(P[ix], num_samples=1, generator=g).item() # случайный выбор по строке P if ix == 0: # выпала «.» — имя закончилось break out.append(itos[ix]) samples.append("".join(out))print(" ".join(samples))вемустатя люша офин ввала падр н мана в нана ленидашарьяЕсли у вас другая версия PyTorch, имена могут отличаться — генератор случайных чисел между версиями не гарантирован.
Результат честно отражает модель. «Люша», «мана», «нана» звучат как имена. «Ленидашарья» склеена из кусочков «лени…», «даша» и «…арья»: каждая пара соседних букв правдоподобна, но модель не помнит, что было три буквы назад, и не знает, что имя уже длинное. «н» и «в» — имена из одной буквы: после «н» и после «в» имя заканчивается с вероятностью около 15% (иван, роман, лев, ярослав), и иногда это происходит сразу. Модель с контекстом в один символ лучше не умеет. Чтобы помнить больше, нужен больший контекст — к этому мы и движемся на всей ступени.
Шаг 4. Как измерить качество: NLL
Заголовок раздела «Шаг 4. Как измерить качество: NLL»Глазами качество не измерить, нужно одно число. Хорошая модель даёт высокую вероятность реальным именам. Вероятность имени по цепному правилу — произведение вероятностей его биграмм. Для «анна»:
Это правдоподобие, и для всего набора имён оно — произведение 1627 чисел меньше единицы,
машинный ноль. Поэтому берём логарифм, ставим минус и усредняем по биграммам — получаем средний NLL
(здесь и дальше — натуральный логарифм, как torch.log и math.log):
Это ровно перекрёстная энтропияперекрёстная энтропияEN cross-entropy из модуля 1.3. Меньше — лучше, 0 — идеальная уверенная модель. Ориентир снизу: равномерная модель, которая ничего не знает, даёт каждой букве и получает .
def nll(names, P): """Среднее −log P по всем биграммам всех имён.""" log_lik, n = 0.0, 0 for name in names: seq = ["."] + list(name) + ["."] for a, b in zip(seq, seq[1:]): log_lik += torch.log(P[stoi[a], stoi[b]]).item() n += 1 return -log_lik / n
print(f"NLL модели: {nll(NAMES, P):.4f} равномерная модель: ln V = {math.log(V):.4f}")for w in ["анна", "ярослава", "ьыа"]: print(f" {w:9} NLL = {nll([w], P):.3f}")NLL модели: 2.1136 равномерная модель: ln V = 3.4340 анна NLL = 1.803 ярослава NLL = 2.097 ьыа NLL = infКак это читать:
- 2.11 против 3.43. Perplexity : модель в среднем выбирает из ~8 равновероятных букв вместо 31.
- «анна» = 1.80 — типичное имя из частых биграмм, оно получило NLL ниже среднего.
- «ьыа» = ∞. Биграмма
.ьни разу не встречалась, её вероятность 0, а . Одна невиданная пара обнуляет вероятность всего слова.
Последний пункт — не просто неприятность. Модель, которая называет что-то невозможным, бесконечно штрафуется, если это всё-таки встретится. Проверим, как часто такое бывает с новыми именами.
Шаг 5. Сглаживание и честная проверка
Заголовок раздела «Шаг 5. Сглаживание и честная проверка»Отложим 24 имени (10%) в валидационную выборкувалидационная выборка (val, dev)EN validation setНужна для выбора гиперпараметров и ранней остановки. Тест при этом не трогаем., построим модель по остальным 218 и посчитаем NLL на обеих частях. Чтобы убрать нули, применим сглаживание Лапласасглаживание Лапласа (add-one)EN Laplace (add-one) smoothingДобавить константу ко всем счётчикам, чтобы невиданные n-граммы не получали нулевую вероятность. Классический вариант — +1 (add-one), с произвольным α > 0 — сглаживание Лидстоуна (add-α); силу α подбирают на валидации.: прибавим ко всем счётчикам константу , как будто каждую пару букв мы уже видели раз.
В знаменателе , потому что добавилось к каждой из клеток строки, и строка снова должна давать
в сумме 1. Классический вариант Лапласа — (add-one), вариант с произвольным называют add-.
Пример: в строке «ф» 15 биграмм, и фу среди них нет. При получаем
— мало, но уже не ноль.
names = NAMES[:] # копия списка, чтобы не перемешать оригиналrandom.Random(0).shuffle(names) # перемешиваем с seed=0: иначе в валидацию попали бы первые 24 имени — сплошь мужские на «а»–«г»val, train = names[:24], names[24:] # 10% имён откладываем для проверкиNtr = torch.zeros((V, V))for name in train: seq = ["."] + list(name) + ["."] for a, b in zip(seq, seq[1:]): Ntr[stoi[a], stoi[b]] += 1for alpha in [0, 0.01, 0.1, 0.3, 1.0]: Pa = (Ntr + alpha) / (Ntr + alpha).sum(dim=1, keepdim=True) # формула P_α из текста выше print(f"alpha = {alpha:<4} train NLL = {nll(train, Pa):.3f} val NLL = {nll(val, Pa):.3f}")alpha = 0 train NLL = 2.095 val NLL = infalpha = 0.01 train NLL = 2.100 val NLL = 2.652alpha = 0.1 train NLL = 2.135 val NLL = 2.512alpha = 0.3 train NLL = 2.197 val NLL = 2.497alpha = 1.0 train NLL = 2.342 val NLL = 2.562Без сглаживания валидационный NLL бесконечен. Достаточно одной невиданной биграммы, а их у нас много: 7 из 24 отложенных имён содержат пару букв, которой не было в обучающих именах.
| Имя из валидации | Невиданные биграммы |
|---|---|
| кузьма | «ку», «уз», «зь», «ьм» |
| пётр | «пё», «ёт» |
| федя, надя | «дя» (и «ед» у «федя») |
| захар | «ах» |
| святослав | «ят» |
| андрюша | «рю» |
Почти треть совершенно обычных имён модель без сглаживания объявляет невозможными. Это разреженность из прошлого урока, только теперь мы её измерили.
Дальше классическая картина из модуля 2.1:
- На обучающих данных NLL растёт с : сглаживание отнимает вероятность у виденного и раздаёт невиданному.
- На валидации есть оптимум: из перебранных значений лучшее (более частый перебор даёт минимум около 0.25). Маленькое — модель слишком уверена в своих 218 обучающих именах (переобучениепереобучениеEN overfitting), большое — слишком размазывает вероятность (недообучение).
— гиперпараметргиперпараметрEN hyperparameterНастройка, которую не учат градиентом, а выбирают по валидации — степень полинома, λ, learning rate, число слоёв., и подбирать его надо на валидации, а не на обучающих данных. Обратите внимание на масштаб: при мы добавляем «фальшивое» наблюдение к 1475 настоящим биграммам обучающей части. На 32 тысячах имён makemore (больше 200 тысяч биграмм) то же почти незаметно, а у нас оно портит модель.
Шаг 6. Та же модель — нейросеть
Заголовок раздела «Шаг 6. Та же модель — нейросеть»Подсчёт работает только для коротких контекстов: для 10 предыдущих букв таблица была бы размером клеток. Нейросеть масштабируется иначе, поэтому переформулируем ту же модель как нейросеть и убедимся, что она выучит ту же самую таблицу.
flowchart LR A["Текущая буква<br/>id = 1"] --> B["one-hot<br/>вектор длины 31"] B --> C["× W (31×31)<br/>= логиты"] C --> D["softmax<br/>P(следующей буквы)"] D --> E["NLL правильной<br/>буквы → loss"]
- Вход — текущая буква как one-hot кодированиеone-hot кодирование (унитарный код)EN one-hot encodingВектор длины V из нулей с единицей в позиции номера токена. Все one-hot векторы попарно ортогональны.-вектор длины : нули и одна единица.
- Один линейный слой размера даёт логитовлогитEN logitСтрого — логарифм шансов log(p / (1 − p)). В нейросетях логитами называют любые выходы до softmax или сигмоиды..
- Softmax превращает логиты в распределение следующей буквы.
- Loss — средний NLL правильной следующей буквы, как в шаге 4.
xs, ys = [], []for name in NAMES: seq = ["."] + list(name) + ["."] for a, b in zip(seq, seq[1:]): xs.append(stoi[a]) # вход: текущая буква ys.append(stoi[b]) # цель: следующая букваxs, ys = torch.tensor(xs), torch.tensor(ys)
g = torch.Generator().manual_seed(2026)W = torch.randn((V, V), generator=g, requires_grad=True) # единственный слой: V входов → V логитовX = F.one_hot(xs, num_classes=V).float() # (1627, 31)opt = torch.optim.Adam([W], lr=0.1)
for step in range(301): logits = X @ W # (1627, 31): по сути строки W[xs] probs = F.softmax(logits, dim=1) # exp(логиты) / сумма по строке # probs[[0, 1, 2, …], ys] — fancy indexing (модуль 0.3): из строки k берём вероятность правильной буквы ys[k] loss = -probs[torch.arange(len(ys)), ys].log().mean() # средний NLL; то же, что F.cross_entropy(logits, ys) opt.zero_grad() # обнулить старые градиенты (модуль 3.3) loss.backward() # посчитать dloss/dW opt.step() # сдвинуть W против градиента if step % 100 == 0: print(f"шаг {step:3}: loss {loss.item():.4f}")
P_nn = F.softmax(W.detach(), dim=1) # таблица вероятностей, которую выучила сетьprint(f"P(. | а): сеть {P_nn[stoi['а'], 0]:.3f}, подсчёт {P[stoi['а'], 0]:.3f}")print(f"P(н | а): сеть {P_nn[stoi['а'], stoi['н']]:.3f}, подсчёт {P[stoi['а'], stoi['н']]:.3f}")print("one-hot @ W == W[xs]:", torch.allclose(X @ W, W[xs]))ia = stoi["а"]seen = N[ia] > 0 # булева маска: какие буквы хоть раз шли после «а»diff = (W.detach()[ia] - torch.log(N[ia].float()))[seen] # логит минус log(счётчик) — только для виденныхprint("W[а] − log N[а] по виденным буквам:", [round(x, 2) for x in diff[:6].tolist()])шаг 0: loss 3.7989шаг 100: loss 2.1227шаг 200: loss 2.1175шаг 300: loss 2.1158P(. | а): сеть 0.404, подсчёт 0.404P(н | а): сеть 0.129, подсчёт 0.129one-hot @ W == W[xs]: TrueW[а] − log N[а] по виденным буквам: [-0.99, -0.99, -0.99, -0.99, -0.99, -1.0]Что здесь произошло:
- Loss сходится к 2.114 — к NLL модели подсчёта. Лучше биграммная модель быть не может: подсчёт и есть оптимум правдоподобия, сеть пришла к нему градиентным спуском.
- Вероятности совпали до третьего знака.
- Логиты — это логарифмы счётчиков. одинаково для всех виденных в строке, а константа в строке softmax не меняет (модуль 1.3). Для невиданных пар и : такие логиты градиентный спуск всё время толкает вниз, но до за конечное число шагов не доводит. Поэтому строка — это «логарифм таблицы счётчиков». Карпатый так и предлагает читать логиты: как логарифмы ненормированных частот (log-counts), а softmax — как «возвести в экспоненту и нормировать».
- One-hot × W = выбор строки. Умножение one-hot вектора на матрицу просто достаёт строку . Ровно так
устроен слой
nn.Embedding— подробно в модуле 4.3.
Сглаживание = регуляризация
Заголовок раздела «Сглаживание = регуляризация»Сглаживание тоже переносится в нейросеть. Добавим к loss штраф . Он тянет все логиты к нулю, а нулевые логиты — это равномерное распределение. Чем больше , тем сильнее модель «размазывает» вероятность, как при большом . Это L2-регуляризация из модуля 2.1, а — тот же гиперпараметр, который подбирают на валидации.
Зачем всё это, если подсчёт уже работает
Заголовок раздела «Зачем всё это, если подсчёт уже работает»Подсчёт упирается в стену при длинном контексте, а нейросеть — нет. Чтобы учесть три предыдущие буквы, достаточно подать на вход три вектора и добавить скрытый слой. Именно так поступили Бенджио и соавторы в 2003 году, и это следующая лекция makemore — мы повторим её в уроке 4.1.4. А вместо one-hot векторов будут обучаемые эмбеддингиэмбеддинг, векторное представлениеEN embedding, где похожие буквы и слова получают похожие векторы. Трансформер — тот же путь, доведённый до контекста в сотни тысяч токенов.
Практика
Заголовок раздела «Практика»Реализуйте биграммную модель на чистом Python, без PyTorch. Задание работает прямо в браузере и проверяет каждую функцию: словарь, счётчики, нормировку со сглаживанием, сэмплирование через переданный генератор и NLL.
import math
import random
# 242 русских имени: полные, уменьшительные и несколько старинных
NAMES = """
александр алексей альберт анатолий андрей антон аркадий арсений артём артур богдан борис вадим
валентин валерий василий вениамин виктор виталий владимир владислав всеволод вячеслав гавриил
геннадий георгий герман глеб григорий давид даниил демид денис дмитрий добрыня евгений егор
емельян ефим захар иван игнат игорь илья иннокентий ипполит кирилл клим константин кузьма
лаврентий лев леонид лука макар максим марк матвей мирон михаил назар никита никифор николай
олег остап павел платон прохор пётр родион роман ростислав руслан савва савелий святослав семён
сергей станислав степан тарас тимофей тимур тихон трофим фёдор филипп эдуард эмиль юрий яков ян
ярослав ада агата александра алёна алина алиса алла анастасия ангелина анжелика анна антонина
арина валентина валерия варвара василиса вера вероника виктория галина дарья диана ева евгения
екатерина елена елизавета есения жанна зинаида злата зоя инна ирина камила карина кира клавдия
кристина ксения лада лариса лидия лилия любовь людмила майя маргарита марина мария милана
мирослава надежда наталья нина нонна оксана олеся ольга полина раиса регина римма роза сабина
светлана серафима снежана софия стефания таисия тамара татьяна ульяна эвелина элина эмилия
юлиана юлия яна ярослава саша маша даша миша коля петя ваня катя лена оля таня наташа женя дима
серёжа лёша андрюша настя юля аня вова витя гриша паша костя ксюша люба надя галя зина тома соня
лиза поля варя федя гоша стёпа тёма слава толя валя рома лёва боря глаша
фаина эльвира ника мила аксинья дарина марфа пелагея ефросинья прасковья евдокия устинья
лукерья фрол фома гордей елисей мстислав любомир велимир
""".split()
def build_vocab(names):
"""Строит словарь символов и возвращает пару (stoi, itos).
stoi["."] == 0 — служебный токен начала и конца имени.
Остальные буквы из names — в алфавитном порядке (sorted) с номерами 1, 2, 3, …
itos — обратный словарь: номер → символ.
"""
# TODO: ваш код
...
def count_bigrams(names, stoi):
"""Матрица счётчиков V×V (список списков int): N[i][j] — сколько раз символ j шёл после символа i.
Каждое имя оборачивается служебным токеном: "анна" → . а н н а .
"""
# TODO: ваш код
...
def to_probs(N, alpha=0.0):
"""Нормирует КАЖДУЮ СТРОКУ: P[i][j] = (N[i][j] + alpha) / (сумма строки i с учётом alpha).
alpha — сглаживание, добавляется к каждой клетке. Если сумма строки равна 0,
верните для неё равномерное распределение.
"""
# TODO: ваш код
...
def sample_name(P, itos, rng):
"""Генерирует одно имя.
Начинаем с токена 0 ("."), сэмплируем следующий символ из строки P[текущий],
пока снова не выпадет 0. Возвращаем строку без точек.
rng — объект random.Random: используйте rng.choices(...) или rng.random(),
а не модуль random напрямую (иначе результат нельзя воспроизвести).
"""
# TODO: ваш код
...
def nll(names, P, stoi):
"""Средний NLL на одну биграмму: −(1/n) · Σ log P[предыдущий][следующий] по всем биграммам всех имён.
Если хотя бы одна биграмма имеет вероятность 0, верните float("inf").
"""
# TODO: ваш код
...
# Кнопка «Запустить» — посмотрите на свою модель:
def demo():
stoi, itos = build_vocab(NAMES)
N = count_bigrams(NAMES, stoi)
P = to_probs(N, alpha=0.0)
print("Размер словаря:", len(stoi))
print("NLL на обучающих именах:", round(nll(NAMES, P, stoi), 4))
print("Равномерная модель, ln V:", round(math.log(len(stoi)), 4))
rng = random.Random(42)
print("Новые имена:", [sample_name(P, itos, rng) for _ in range(8)])
try:
demo()
except Exception as e: # пока функции не дописаны
print("Допишите функции:", type(e).__name__, e)
Когда тесты пройдут, поэкспериментируйте:
- Сгенерируйте 20 имён при
alpha=0и приalpha=1. Почему приalpha=1чаще появляются длинные бессвязные имена с редкими буквами? Почему изредка получается пустое имя, хотя приalpha=0этого не бывает никогда? Подсказка: посмотрите наP[0][0]. - Найдите в
NAMESимя с самым высоким NLL. Почему оно «неудобно» модели? - Отделите мужские имена от женских и обучите две модели. Чем отличаются их строки для
.— первые и последние буквы?
Проверь себя
Заголовок раздела «Проверь себя»- Биграммная модель — таблица вероятностей следующего символа. Служебный токен «.» учит начало и конец имени.
- Обучение подсчётом — это оценка максимального правдоподобия. Нормируйте строки (
keepdim=True!). - Генерация — авторегрессионный цикл сэмплирования до «.». Качество модели меряют средним NLL: у нас 2.11 против у равномерной модели.
- Невиданные биграммы дают бесконечный NLL. Сглаживание лечит это, а его силу подбирают на валидации.
- Однослойная сеть на one-hot входе выучивает ту же таблицу: логиты — логарифмы счётчиков, one-hot × W — выбор строки, L2-регуляризация — аналог сглаживания.
Дальше: как превращать распределение следующего токена в текст — greedy, температура, top-k, top-p и штрафы за повторение — Генерация текста и сэмплирование.