Перейти к содержимому
Ступень 44.1 Языковое моделирование

Биграммная модель на русских именах

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

УРОКstage-4/4-1-language-modeling/02-bigram-model.mdx2 чУрок 2 из 5
После урока вы сможете
  • Построить биграммную модель подсчётом и сгенерировать из неё новые имена
  • Оценить языковую модель средним NLL и сравнить её с равномерной моделью (ln⁡V\ln V)
  • Объяснить, зачем нужно сглаживание, и подобрать его силу на отложенной выборке
  • Показать, что однослойная нейросеть на one-hot входе выучивает ту же таблицу, что и подсчёт

В прошлом уроке мы выяснили, что языковой модели достаточно уметь предсказывать следующий токен. Сегодня построим такую модель целиком — от данных до генерации — на самой простой задаче: придумывать новые русские имена. Токеном будет буква, а модель будет смотреть только на одну предыдущую букву. Это биграммнаябиграмма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 имени: полные мужские и женские (александр, мария), уменьшительные (саша, ксюша, стёпа) и несколько старинных (добрыня, пелагея, ефросинья). Все в нижнем регистре, буква «ё» сохранена. Сохраните список в файл names.py:

names.py
NAMES = """
александр алексей альберт анатолий андрей антон аркадий арсений артём артур богдан борис вадим
валентин валерий василий вениамин виктор виталий владимир владислав всеволод вячеслав гавриил
геннадий георгий герман глеб григорий давид даниил демид денис дмитрий добрыня евгений егор
емельян ефим захар иван игнат игорь илья иннокентий ипполит кирилл клим константин кузьма
лаврентий лев леонид лука макар максим марк матвей мирон михаил назар никита никифор николай
олег остап павел платон прохор пётр родион роман ростислав руслан савва савелий святослав семён
сергей станислав степан тарас тимофей тимур тихон трофим фёдор филипп эдуард эмиль юрий яков ян
ярослав ада агата александра алёна алина алиса алла анастасия ангелина анжелика анна антонина
арина валентина валерия варвара василиса вера вероника виктория галина дарья диана ева евгения
екатерина елена елизавета есения жанна зинаида злата зоя инна ирина камила карина кира клавдия
кристина ксения лада лариса лидия лилия любовь людмила майя маргарита марина мария милана
мирослава надежда наталья нина нонна оксана олеся ольга полина раиса регина римма роза сабина
светлана серафима снежана софия стефания таисия тамара татьяна ульяна эвелина элина эмилия
юлиана юлия яна ярослава саша маша даша миша коля петя ваня катя лена оля таня наташа женя дима
серёжа лёша андрюша настя юля аня вова витя гриша паша костя ксюша люба надя галя зина тома соня
лиза поля варя федя гоша стёпа тёма слава толя валя рома лёва боря глаша
фаина эльвира ника мила аксинья дарина марфа пелагея ефросинья прасковья евдокия устинья
лукерья фрол фома гордей елисей мстислав любомир велимир
""".split()

Для сравнения: в оригинальном makemore 32 тысячи английских имён. У нас данных в сто с лишним раз меньше, и это скажется на качестве. Зато все эффекты будут видны особенно ярко.

Токены — буквы. Добавим служебный токен ., который означает и начало, и конец имени. Имя «анна» превращается в последовательность . а н н а . и даёт пять биграмм: .а, ан, нн, на, а.. Биграмма .а учит модель, что имена часто начинаются на «а», а а. — что на «а» они часто заканчиваются. Без служебного токена модель не знала бы, где остановиться.

Счётчики сложим в матрицу NN размера V×VV \times V: N[i,j]N[i, j] — сколько раз символ jj шёл сразу после символа ii.

import math
import random
import torch
import torch.nn.functional as F
from 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 букв (нет «ц», «щ» и «ъ»), плюс служебный токен — V=31V = 31. Самая частая биграмма а.: 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 она ударит по нам всерьёз.

Строка N[i]N[i] — это сколько раз какие буквы шли после буквы ii. Чтобы получить распределение P(xt∣xt−1=i)P(x_t \mid x_{t-1} = i), делим каждую строку на её сумму. Это та же оценка максимального правдоподобия, что в модуле 1.3: частота — лучшая оценка вероятности.

P[i,j]=N[i,j]∑kN[i,k]P[i, j] = \frac{N[i, j]}{\sum_k N[i, k]}
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.0
P(. | а) = 0.404 P(н | а) = 0.129
Первая буква: а 0.107, в 0.099, с 0.074, м 0.070

После «а» имя заканчивается в 40% случаев, а продолжается на «н» — в 13%. Строка 0 — распределение первых букв: чаще всего имена начинаются на «а», «в», «с», «м».

Генерация — авторегрессия из прошлого урока в миниатюре. Стартуем с . (номер 0), берём строку P[0]P[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% (иван, роман, лев, ярослав), и иногда это происходит сразу. Модель с контекстом в один символ лучше не умеет. Чтобы помнить больше, нужен больший контекст — к этому мы и движемся на всей ступени.

Глазами качество не измерить, нужно одно число. Хорошая модель даёт высокую вероятность реальным именам. Вероятность имени по цепному правилу — произведение вероятностей его биграмм. Для «анна»:

P(анна)=P(а∣.) P(н∣а) P(н∣н) P(а∣н) P(.∣а)P(\text{анна}) = P(\text{а} \mid .)\,P(\text{н} \mid \text{а})\,P(\text{н} \mid \text{н})\,P(\text{а} \mid \text{н})\,P(. \mid \text{а})

Это правдоподобие, и для всего набора имён оно — произведение 1627 чисел меньше единицы, машинный ноль. Поэтому берём логарифм, ставим минус и усредняем по биграммам — получаем средний NLL (здесь и дальше log⁡\log — натуральный логарифм, как torch.log и math.log):

NLL=−1n∑все биграммы (a,b)log⁡P[a,b]\text{NLL} = -\frac{1}{n} \sum_{\text{все биграммы } (a, b)} \log P[a, b]

Это ровно перекрёстная энтропияперекрёстная энтропияEN cross-entropy из модуля 1.3. Меньше — лучше, 0 — идеальная уверенная модель. Ориентир снизу: равномерная модель, которая ничего не знает, даёт каждой букве 1/V1/V и получает ln⁡V\ln V.

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 e2.11≈8.3e^{2.11} \approx 8.3: модель в среднем выбирает из ~8 равновероятных букв вместо 31.
  • «анна» = 1.80 — типичное имя из частых биграмм, оно получило NLL ниже среднего.
  • «ьыа» = ∞. Биграмма .ь ни разу не встречалась, её вероятность 0, а −log⁡0=∞-\log 0 = \infty. Одна невиданная пара обнуляет вероятность всего слова.

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

Отложим 24 имени (10%) в валидационную выборкувалидационная выборка (val, dev)EN validation setНужна для выбора гиперпараметров и ранней остановки. Тест при этом не трогаем., построим модель по остальным 218 и посчитаем NLL на обеих частях. Чтобы убрать нули, применим сглаживание Лапласасглаживание Лапласа (add-one)EN Laplace (add-one) smoothingДобавить константу ко всем счётчикам, чтобы невиданные n-граммы не получали нулевую вероятность. Классический вариант — +1 (add-one), с произвольным α > 0 — сглаживание Лидстоуна (add-α); силу α подбирают на валидации.: прибавим ко всем счётчикам константу α\alpha, как будто каждую пару букв мы уже видели α\alpha раз.

Pα[i,j]=N[i,j]+α∑kN[i,k]+αVP_\alpha[i, j] = \frac{N[i, j] + \alpha}{\sum_k N[i, k] + \alpha V}

В знаменателе +αV+\alpha V, потому что α\alpha добавилось к каждой из VV клеток строки, и строка снова должна давать в сумме 1. Классический вариант Лапласа — α=1\alpha = 1 (add-one), вариант с произвольным α\alpha называют add-α\alpha. Пример: в строке «ф» 15 биграмм, и фу среди них нет. При α=0.1\alpha = 0.1 получаем P(у∣ф)=0.1/(15+0.1⋅31)≈0.006P(\text{у} \mid \text{ф}) = 0.1 / (15 + 0.1 \cdot 31) \approx 0.006 — мало, но уже не ноль.

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]] += 1
for 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 = inf
alpha = 0.01 train NLL = 2.100 val NLL = 2.652
alpha = 0.1 train NLL = 2.135 val NLL = 2.512
alpha = 0.3 train NLL = 2.197 val NLL = 2.497
alpha = 1.0 train NLL = 2.342 val NLL = 2.562

Без сглаживания валидационный NLL бесконечен. Достаточно одной невиданной биграммы, а их у нас много: 7 из 24 отложенных имён содержат пару букв, которой не было в обучающих именах.

Имя из валидацииНевиданные биграммы
кузьма«ку», «уз», «зь», «ьм»
пётр«пё», «ёт»
федя, надя«дя» (и «ед» у «федя»)
захар«ах»
святослав«ят»
андрюша«рю»

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

Дальше классическая картина из модуля 2.1:

  • На обучающих данных NLL растёт с α\alpha: сглаживание отнимает вероятность у виденного и раздаёт невиданному.
  • На валидации есть оптимум: из перебранных значений лучшее α=0.3\alpha = 0.3 (более частый перебор даёт минимум около 0.25). Маленькое α\alpha — модель слишком уверена в своих 218 обучающих именах (переобучениепереобучениеEN overfitting), большое — слишком размазывает вероятность (недообучение).

α\alpha — гиперпараметргиперпараметрEN hyperparameterНастройка, которую не учат градиентом, а выбирают по валидации — степень полинома, λ, learning rate, число слоёв., и подбирать его надо на валидации, а не на обучающих данных. Обратите внимание на масштаб: при α=1\alpha = 1 мы добавляем 31⋅31=96131 \cdot 31 = 961 «фальшивое» наблюдение к 1475 настоящим биграммам обучающей части. На 32 тысячах имён makemore (больше 200 тысяч биграмм) то же α=1\alpha = 1 почти незаметно, а у нас оно портит модель.

Подсчёт работает только для коротких контекстов: для 10 предыдущих букв таблица была бы размером 3111≈2.5⋅101631^{11} \approx 2.5 \cdot 10^{16} клеток. Нейросеть масштабируется иначе, поэтому переформулируем ту же модель как нейросеть и убедимся, что она выучит ту же самую таблицу.

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"]
Биграммная модель как однослойная нейросеть без сдвига и без нелинейности
  1. Вход — текущая буква как one-hot кодированиеone-hot кодирование (унитарный код)EN one-hot encodingВектор длины V из нулей с единицей в позиции номера токена. Все one-hot векторы попарно ортогональны.-вектор длины VV: нули и одна единица.
  2. Один линейный слой WW размера V×VV \times V даёт VV логитовлогитEN logitСтрого — логарифм шансов log(p / (1 − p)). В нейросетях логитами называют любые выходы до softmax или сигмоиды..
  3. Softmax превращает логиты в распределение следующей буквы.
  4. 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.1158
P(. | а): сеть 0.404, подсчёт 0.404
P(н | а): сеть 0.129, подсчёт 0.129
one-hot @ W == W[xs]: True
W[а] − log N[а] по виденным буквам: [-0.99, -0.99, -0.99, -0.99, -0.99, -1.0]

Что здесь произошло:

  • Loss сходится к 2.114 — к NLL модели подсчёта. Лучше биграммная модель быть не может: подсчёт и есть оптимум правдоподобия, сеть пришла к нему градиентным спуском.
  • Вероятности совпали до третьего знака.
  • Логиты — это логарифмы счётчиков. W[i,j]−log⁡N[i,j]W[i, j] - \log N[i, j] одинаково для всех виденных jj в строке, а константа в строке softmax не меняет (модуль 1.3). Для невиданных пар N[i,j]=0N[i, j] = 0 и log⁡0=−∞\log 0 = -\infty: такие логиты градиентный спуск всё время толкает вниз, но до −∞-\infty за конечное число шагов не доводит. Поэтому строка WW — это «логарифм таблицы счётчиков». Карпатый так и предлагает читать логиты: как логарифмы ненормированных частот (log-counts), а softmax — как «возвести в экспоненту и нормировать».
  • One-hot × W = выбор строки. Умножение one-hot вектора на матрицу просто достаёт строку W[x]W[x]. Ровно так устроен слой nn.Embedding — подробно в модуле 4.3.

Сглаживание тоже переносится в нейросеть. Добавим к loss штраф λ⋅mean(W2)\lambda \cdot \text{mean}(W^2). Он тянет все логиты к нулю, а нулевые логиты — это равномерное распределение. Чем больше λ\lambda, тем сильнее модель «размазывает» вероятность, как при большом α\alpha. Это L2-регуляризация из модуля 2.1, а λ\lambda — тот же гиперпараметр, который подбирают на валидации.

Подсчёт упирается в стену при длинном контексте, а нейросеть — нет. Чтобы учесть три предыдущие буквы, достаточно подать на вход три вектора и добавить скрытый слой. Именно так поступили Бенджио и соавторы в 2003 году, и это следующая лекция makemore — мы повторим её в уроке 4.1.4. А вместо one-hot векторов будут обучаемые эмбеддингиэмбеддинг, векторное представлениеEN embedding, где похожие буквы и слова получают похожие векторы. Трансформер — тот же путь, доведённый до контекста в сотни тысяч токенов.

Реализуйте биграммную модель на чистом Python, без PyTorch. Задание работает прямо в браузере и проверяет каждую функцию: словарь, счётчики, нормировку со сглаживанием, сэмплирование через переданный генератор и NLL.

Биграммная модель на русских именах8 тестов
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)

Когда тесты пройдут, поэкспериментируйте:

  1. Сгенерируйте 20 имён при alpha=0 и при alpha=1. Почему при alpha=1 чаще появляются длинные бессвязные имена с редкими буквами? Почему изредка получается пустое имя, хотя при alpha=0 этого не бывает никогда? Подсказка: посмотрите на P[0][0].
  2. Найдите в NAMES имя с самым высоким NLL. Почему оно «неудобно» модели?
  3. Отделите мужские имена от женских и обучите две модели. Чем отличаются их строки для . — первые и последние буквы?
Проверь себя6 вопросов
1 Сколько биграмм (с учётом «.» в начале и в конце) даёт имя «пётр»?
2 Вы написали P = N / N.sum(dim=1) без keepdim=True. Что произойдёт?
3 Словарь из 31 символа. Каким будет средний NLL модели, которая всем буквам даёт одинаковую вероятность? Округлите до двух знаков.
4 Почему без сглаживания NLL на валидации равен бесконечности?
5 С ростом α\alpha train NLL растёт: 2.095 → 2.100 → 2.135 → 2.197 → 2.342. Val NLL: ∞ → 2.652 → 2.512 → 2.497 → 2.562. Какое α\alpha из {0,0.01,0.1,0.3,1}\{0, 0.01, 0.1, 0.3, 1\} выбрать?
6 Выберите все верные утверждения про нейросетевую версию биграммной модели.Можно выбрать несколько ответов
  • Биграммная модель — таблица V×VV \times V вероятностей следующего символа. Служебный токен «.» учит начало и конец имени.
  • Обучение подсчётом — это оценка максимального правдоподобия. Нормируйте строки (keepdim=True!).
  • Генерация — авторегрессионный цикл сэмплирования до «.». Качество модели меряют средним NLL: у нас 2.11 против ln⁡31=3.43\ln 31 = 3.43 у равномерной модели.
  • Невиданные биграммы дают бесконечный NLL. Сглаживание α\alpha лечит это, а его силу подбирают на валидации.
  • Однослойная сеть на one-hot входе выучивает ту же таблицу: логиты — логарифмы счётчиков, one-hot × W — выбор строки, L2-регуляризация — аналог сглаживания.

Дальше: как превращать распределение следующего токена в текст — greedy, температура, top-k, top-p и штрафы за повторение — Генерация текста и сэмплирование.