Перейти к содержимому
Ступень 44.6 GPT с нуля

Микро-GPT на чистом Python

Собираем полную GPT — токенизатор, автоград, внимание, MLP, обучение и генерацию — примерно в 150 строк чистого Python и обучаем её на русских именах прямо в браузере.

УРОКstage-4/4-6-gpt-from-scratch/01-micro-gpt.mdx2 чУрок 1 из 5
После урока вы сможете
  • Назвать все части GPT по порядку и объяснить, что каждая делает с вектором токена
  • Собрать блок трансформера из готовых функций и обучить микро-GPT на русских именах в браузере
  • Посчитать параметры маленькой GPT вручную и проверить, что loss свежей модели равен ln⁡V\ln V
  • Объяснить, чем микро-GPT отличается от настоящей LLM и почему «всё остальное — эффективность»

За последние модули вы собрали все детали языковой модели по отдельности: биграммную, MLP- и рекуррентную модели (4.1), токены (4.2), эмбеддинги (4.3), внимание и многоголовое внимание (4.4), блок трансформера и BERT (4.5). Каждую деталь вы проверили тестами. Но пока их никто не соединял в одно целое, и остаётся ощущение, что настоящая GPT — это что-то большее, чем сумма частей.

В этом уроке мы соединим всё в одном файле на чистом Python: без NumPy, без PyTorch, без единой библиотеки. Каждое число модели — это объект Value из нашего автограда (модуль 3.2). Получится полноценная decoder-onlydecoder-only (только декодер)EN decoder-only architectureОдна стопка блоков с причинной маской, цель — следующий токен. Архитектура GPT, Llama, Qwen, DeepSeek, Kimi, GLM-4.5. GPT: она учится, считает градиенты и генерирует новые русские имена. Тот же алгоритм, что у GPT-2, Qwen и DeepSeek, только крошечный.

Идея не наша. Андрей Карпатый в начале 2026 года опубликовал microgpt — GPT примерно в 200 строк на чистом Python с собственным автоградом. Его главный тезис: в таком файле весь алгоритм, а всё остальное — эффективность. Мы напишем свою версию (около 150 строк) на тех же 242 русских именах, что и в биграммной модели, и честно сравним две модели.

Вот путь одного токена через микро-GPT. Запомните его: в PyTorch-версии (следующий урок) и в Qwen3 он тот же, меняются только размеры и детали.

flowchart TD
A["Токен «н» на позиции 2"] --> B["wte[н] + wpe[2]<br/>вектор из 16 чисел"]
B --> N0["RMSNorm"]
N0 --> C
subgraph C["Блок трансформера (×1)"]
  C1["RMSNorm → q, k, v"] --> C2["k, v → в кэш слоя"]
  C2 --> C3["Внимание 4 голов по кэшу"]
  C3 --> C4["W_O, + остаток"]
  C4 --> C5["RMSNorm → MLP 16→64→16 (ReLU), + остаток"]
end
C --> D["LM-голова: 31 логит"]
D --> E["softmax → вероятности следующей буквы"]
E --> F["loss = −log p(правильной буквы)"]
Микро-GPT: один токен за один вызов. Ключи и значения прошлых токенов лежат в кэше.

Пять частей, которые есть в любой GPT:

  1. Токенизатор — текст в номера.
  2. Эмбеддинги — номер токена и номер позиции в векторы, которые складываются.
  3. Блоки трансформера — внимание (токен собирает информацию из прошлого) и MLP (токен её обрабатывает).
  4. LM-головаLM-голова (языковая голова)EN LM head (language modeling head)Последний линейный слой GPT: превращает вектор ширины d в V логитов, по одному на токен словаря. Часто делит матрицу с эмбеддингами (weight tying). — вектор в логитылогитEN logitСтрого — логарифм шансов log(p / (1 − p)). В нейросетях логитами называют любые выходы до softmax или сигмоиды., по одному числу на каждый токен словаря.
  5. Loss и оптимизатор — перекрёстная энтропияперекрёстная энтропияEN cross-entropy и Adam.

Разберём их по шагам. Все фрагменты кода ниже — части одного файла micro_gpt.py; склейте их по порядку, и он запустится.

Берём 242 имени из урока 4.1.2: положите рядом файл names.py со списком NAMES из биграммного урока. Порядок имён важен: от него зависит, какие 24 имени попадут в валидацию. Токенизатор — посимвольныйпосимвольная токенизацияEN character-level tokenizationКаждый символ — отдельный токен. Словарь крошечный (десятки–сотни), зато последовательности длинные. Удобна для учебных моделей; в LLM вместо неё byte-level BPE.: каждая буква — отдельный токен, а точка . с номером 0 означает и начало, и конец имени. В именах встречаются 30 разных букв, значит словарь V=31V = 31.

import math
import random
import time
from names import NAMES # 242 имени из урока 4.1.2 (файл names.py)
chars = sorted(set("".join(NAMES))) # 30 букв по алфавиту (ё — последняя)
stoi = {ch: i + 1 for i, ch in enumerate(chars)} # буква → номер 1…30
stoi["."] = 0 # 0 — начало и конец имени
itos = {i: ch for ch, i in stoi.items()}
V = len(stoi) # 31

Имя «анна» превращается в [0, 1, 14, 14, 1, 0]. Из этой последовательности получается пять обучающих примеров: по «.» предсказать «а», по «.а» — «н», по «.ан» — «н», по «.анн» — «а», по «.анна» — «.». Модель учится на всех позициях сразу, как и большие LLM.

Это Value из модуля 3.2 с тремя изменениями, которые нужны для графа размером в десятки тысяч узлов:

  • локальные производные считаются сразу в прямом проходе и хранятся в _local_grads, поэтому backward один на все операции: child.grad += local * v.grad;
  • топологическая сортировка без рекурсии — на явном стеке. Самая длинная цепочка в графе одного шага — около 270 узлов, и каждый новый слой модели добавляет ещё примерно 200. Уже на пяти слоях рекурсивная версия упала бы с RecursionError: предел Python — около 1000 вложенных вызовов (вспомните предупреждение из урока 3.2);
  • __slots__ — объект без словаря атрибутов, так он занимает меньше памяти.
class Value:
__slots__ = ("data", "grad", "_children", "_local_grads")
def __init__(self, data, children=(), local_grads=()):
self.data = data
self.grad = 0.0
self._children = children # из каких узлов получено
self._local_grads = local_grads # d(self)/d(child) для каждого ребёнка
def __add__(self, other):
if isinstance(other, Value):
return Value(self.data + other.data, (self, other), (1.0, 1.0))
return Value(self.data + other, (self,), (1.0,))
def __mul__(self, other):
if isinstance(other, Value):
return Value(self.data * other.data, (self, other), (other.data, self.data))
return Value(self.data * other, (self,), (other,))
def __pow__(self, k): # k — обычное число
return Value(self.data**k, (self,), (k * self.data ** (k - 1),))
def exp(self):
e = math.exp(self.data)
return Value(e, (self,), (e,))
def log(self):
return Value(math.log(self.data), (self,), (1.0 / self.data,))
def relu(self):
return Value(max(0.0, self.data), (self,), (float(self.data > 0),))
__radd__ = __add__
__rmul__ = __mul__
def __neg__(self):
return self * -1.0
def __sub__(self, other):
return self + (-other)
def __truediv__(self, other):
return self * other**-1
def backward(self):
topo, seen = [], set()
stack = [(self, False)] # обход в глубину без рекурсии
while stack:
v, ready = stack.pop()
if ready:
topo.append(v)
elif v not in seen:
seen.add(v)
stack.append((v, True))
stack.extend((c, False) for c in v._children)
self.grad = 1.0
for v in reversed(topo): # цепное правило от выхода к листьям
for child, local in zip(v._children, v._local_grads):
child.grad += local * v.grad

Параметры — это матрицы, а матрица у нас — список строк из Value. Размеры модели крошечные: ширина d=16d = 16, 4 головы по 4 числа, один слой, до 12 позиций (самое длинное имя — 10 букв, плюс точка в начале).

n_embd, n_head, n_layer, block_size = 16, 4, 1, 12
head_dim = n_embd // n_head
random.seed(42)
def matrix(n_out, n_in, std=0.08):
return [[Value(random.gauss(0, std)) for _ in range(n_in)] for _ in range(n_out)]
P = {"wte": matrix(V, n_embd), "wpe": matrix(block_size, n_embd), "lm_head": matrix(V, n_embd)}
for i in range(n_layer):
P[f"{i}.wq"], P[f"{i}.wk"] = matrix(n_embd, n_embd), matrix(n_embd, n_embd)
P[f"{i}.wv"], P[f"{i}.wo"] = matrix(n_embd, n_embd), matrix(n_embd, n_embd)
P[f"{i}.fc1"], P[f"{i}.fc2"] = matrix(4 * n_embd, n_embd), matrix(n_embd, 4 * n_embd)
params = [p for m in P.values() for row in m for p in row]
print("параметров:", len(params))

Посчитаем вручную, это полезная привычка:

МатрицаФормаПараметровЧто делает
wte31×1631 \times 16496эмбеддинг токена
wpe12×1612 \times 16192эмбеддинг позицииобучаемые позиционные эмбеддингиEN learned (absolute) position embeddingsТаблица векторов по одному на позицию, обучается вместе с моделью (BERT — 512 позиций, GPT-2 — 1024). За пределами таблицы позиций просто нет.
wq, wk, wv, wo4 × 16×1616 \times 161024внимание, 4d24d^2
fc1, fc264×1664 \times 16 и 16×6416 \times 642048MLP, 8d28d^2
lm_head31×1631 \times 16496логиты
Итого4256

Запомните пропорцию: в блоке на внимание приходится 4d24d^2, на MLP — 8d28d^2, всего 12d212d^2 на слой. Эта формула работает и для GPT-2, и для GPT-3: у GPT-3 175B 96⋅12⋅12 2882≈17496 \cdot 12 \cdot 12\,288^2 \approx 174 млрд параметров в блоках.

Три функции, из которых собрано всё остальное:

def linear(x, w): # y = W x, W — список строк
return [sum(wi * xi for wi, xi in zip(row, x)) for row in w]
def softmax(logits):
m = max(v.data for v in logits) # вычитаем максимум (урок 1.3)
exps = [(v - m).exp() for v in logits]
total = sum(exps)
return [e / total for e in exps]
def rmsnorm(x): # делим вектор на его среднеквадратичную длину
ms = sum(xi * xi for xi in x) / len(x)
return [xi * (ms + 1e-5) ** -0.5 for xi in x]

linear — умножение матрицы на вектор: строка матрицы скалярно умножается на вход. Без смещения (bias): в Llama, Qwen и DeepSeek его тоже нет.

RMSNormRMSNorm (нормализация по среднеквадратичному)EN root mean square layer normalization (RMSNorm)x / sqrt(mean(x²) + ε) · g — LayerNorm без вычитания среднего и без сдвига β (Zhang, Sennrich, 2019). Стандарт Llama, Qwen, DeepSeek, MiniMind. приводит вектор к единичной «громкости»:

RMSNorm(x)i=xi1d∑j=1dxj2+ε\mathrm{RMSNorm}(x)_i = \frac{x_i}{\sqrt{\frac{1}{d}\sum_{j=1}^{d} x_j^2 + \varepsilon}}

Пример: x=(3,4)x = (3, 4). Среднее квадратов (9+16)/2=12.5(9 + 16)/2 = 12.5, корень ≈3.54\approx 3.54, результат ≈(0.85,1.13)\approx (0.85, 1.13). Направление вектора то же, а средний квадрат координат стал равен 1. Это не даёт активациям разрастаться от слоя к слою (подробно — в уроке 3.4.1). В настоящих моделях у RMSNorm есть ещё обучаемый множитель gig_i; мы его опустили.

Самая интересная часть. Функция gpt получает один токен и его позицию, а ключи и значения всех прошлых токенов берёт из списков keys и values:

def gpt(token, pos, keys, values):
x = [t + p for t, p in zip(P["wte"][token], P["wpe"][pos])] # эмбеддинг токена + позиции
x = rmsnorm(x)
for li in range(n_layer):
# 5a. Внимание: pre-norm → q, k, v → головы → W_O → остаточная связь
res = x
x = rmsnorm(x)
q, k, v = linear(x, P[f"{li}.wq"]), linear(x, P[f"{li}.wk"]), linear(x, P[f"{li}.wv"])
keys[li].append(k) # кладём ключ и значение текущего токена в «кэш»
values[li].append(v)
heads = []
for h in range(n_head):
s = slice(h * head_dim, (h + 1) * head_dim) # координаты головы h: q[s] — то же, что q[4*h : 4*h+4]
# оценка с каждым ключом из кэша: скалярное произведение кусков q и k, делённое на √head_dim
scores = [sum(a * b for a, b in zip(q[s], kt[s])) / math.sqrt(head_dim) for kt in keys[li]]
w = softmax(scores) # только по прошлым и текущему токену: маска не нужна
# выход головы: для каждой из 4 координат j — взвешенная сумма значений по всем токенам t
heads += [sum(w[t] * values[li][t][s][j] for t in range(len(w))) for j in range(head_dim)]
x = [a + b for a, b in zip(linear(heads, P[f"{li}.wo"]), res)]
# 5b. MLP: pre-norm → расширяем в 4 раза → ReLU → сжимаем → остаточная связь
res = x
h = [hi.relu() for hi in linear(rmsnorm(x), P[f"{li}.fc1"])]
x = [a + b for a, b in zip(linear(h, P[f"{li}.fc2"]), res)]
return linear(x, P["lm_head"]) # логиты: по одному числу на каждый из V токенов

Пройдём по ней на имени «анна», на позиции 2 (токен «н»):

  1. Эмбеддинги. Строка 14 таблицы wte (буква «н») плюс строка 2 таблицы wpe. Получается вектор из 16 чисел: «какая буква» и «где она стоит» в одном векторе. Его сразу пропускаем через RMSNorm, чтобы в блок пришёл вектор привычного масштаба.
  2. Внимание. Из нормированного вектора делаем запрос qq, ключ kk и значение vv (урок 4.4.2). Ключ и значение дописываем в списки: теперь там три записи — для «.», «а» и «н». Каждая голова сравнивает свой кусок qq (4 числа) с кусками трёх ключей, делит на 4=2\sqrt{4} = 2, берёт softmax и усредняет значения.
  3. Остаточная связьостаточная связьEN residual connection. Результат внимания после WOW_O прибавляется к входу, а не заменяет его. Это остаточный потокостаточный потокEN residual streamВектор шириной d, который течёт сквозь все блоки. Каждый подслой читает из него и дописывает поправку, так что выход = вход + сумма всех поправок.: каждый подслой дописывает в него поправку.
  4. MLP. Норма, расширение 16→6416 \to 64, ReLUReLU (выпрямитель)EN rectified linear unit (ReLU)ReLU(z) = max(0, z). Дешёвая, не насыщается при z > 0; минус — «мёртвые» нейроны, у которых z < 0 на всех примерах., сжатие 64→1664 \to 16, снова прибавляем к потоку. Если внимание — «собрать информацию у соседей», то MLP — «подумать над собранным» в одиночку.
  5. LM-голова. Матрица 31×1631 \times 16 превращает вектор в 31 логит, по одному на каждую букву и точку.

В уроке 4.4.2 мы ставили −∞-\infty на будущие позиции, чтобы токен не подсматривал. Здесь маски нет, и она не нужна: токен «н» на позиции 2 видит только то, что уже лежит в keys — три записи. Ключа следующей «а» там просто ещё нет: её ключ появится, когда мы вызовем gpt для позиции 3.

Эти списки и есть KV-кэшKV-кэшEN KV cache: ключи и значения прошлых токенов вычисляются один раз и хранятся. В PyTorch-версии мы будем обрабатывать все позиции сразу матрицами и вернём маску. А KV-кэш вернётся в уроке 4.6.4, где он ускорит генерацию.

Loss одного имени — средний отрицательное лог-правдоподобиеотрицательное лог-правдоподобие (NLL)EN negative log-likelihood (NLL)−log P(данные | модель), обычно усреднённое по токенам. Для языковой модели совпадает с cross-entropy. по всем его позициям. Обратите внимание: кэш свой у каждого имени, имена друг друга не видят.

def name_loss(name):
"""Средний NLL по всем позициям имени: «.анна.» → 5 предсказаний."""
toks = [0] + [stoi[c] for c in name] + [0]
n = min(block_size, len(toks) - 1)
keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)]
losses = []
for pos in range(n):
probs = softmax(gpt(toks[pos], pos, keys, values))
losses.append(-probs[toks[pos + 1]].log())
return sum(losses) * (1.0 / n), n

Обучение — одно имя за шаг и AdamAdamEN Adam (adaptive moment estimation)Momentum + RMSProp + поправка смещения. Стандартный оптимизатор нейросетей с 2015 года. из урока 1.4.2, написанный вручную. Learning rate линейно убывает от 0.01 до нуля: большие шаги в начале, аккуратные в конце. Отложим те же 24 имени, что в уроке 4.1.2, чтобы сравнение с биграммной моделью было честным.

names = NAMES[:]
random.Random(0).shuffle(names)
val, train = names[:24], names[24:] # то же разбиение, что в уроке 4.1.2
steps, lr, b1, b2 = 2000, 0.01, 0.85, 0.99
m = [0.0] * len(params)
v = [0.0] * len(params)
t0 = time.time()
for step in range(steps):
loss, _ = name_loss(train[step % len(train)])
loss.backward()
lr_t = lr * (1 - step / steps) # линейное убывание: 0.01 на шаге 0, 0.005 на середине, ≈0 в конце
for i, p in enumerate(params): # Adam (урок 1.4.2)
m[i] = b1 * m[i] + (1 - b1) * p.grad
v[i] = b2 * v[i] + (1 - b2) * p.grad**2
m_hat = m[i] / (1 - b1 ** (step + 1))
v_hat = v[i] / (1 - b2 ** (step + 1))
p.data -= lr_t * m_hat / (v_hat**0.5 + 1e-8)
p.grad = 0.0 # не забываем обнулить
if step % 250 == 0 or step == steps - 1:
print(f"шаг {step:4d} loss {loss.data:.3f} ({time.time() - t0:.0f} с)")
def mean_nll(data):
total = count = 0
for name in data:
loss, n = name_loss(name)
total += loss.data * n
count += n
return total / count
print(f"train NLL {mean_nll(train):.3f} val NLL {mean_nll(val):.3f}")

И генерация: начинаем с точки, сэмплируем букву за буквой с температуройтемпература (сэмплирования)EN temperatureЛогиты делятся на T перед softmax. T < 1 — распределение острее, T > 1 — площе, T → 0 — greedy. 0.6, пока модель не выдаст точку (урок 4.1.3).

random.seed(7)
out = []
for _ in range(10):
keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)]
token, name = 0, ""
for pos in range(block_size):
probs = softmax([z * (1 / 0.6) for z in gpt(token, pos, keys, values)]) # логиты делим на T = 0.6
token = random.choices(range(V), weights=[p.data for p in probs])[0] # случайная буква по вероятностям
if token == 0:
break
name += itos[token]
out.append(name)
print(" ".join(out))

Наш запуск (2000 шагов, около 9 проходов по обучающим именам):

параметров: 4256
шаг 0 loss 3.360 (0 с)
шаг 250 loss 3.056 (69 с)
шаг 500 loss 2.189 (149 с)
шаг 750 loss 2.419 (230 с)
шаг 1000 loss 2.495 (353 с)
шаг 1250 loss 2.240 (464 с)
шаг 1500 loss 2.036 (578 с)
шаг 1750 loss 1.592 (684 с)
шаг 1999 loss 1.943 (786 с)
train NLL 1.660 val NLL 2.513
гара даран анта милар сава самина алина иксона слаша олигей

Время зависит от процессора: у нас вышло 13 минут на сильно загруженном сервере, на свободной машине обычно быстрее. Числа loss у вас, скорее всего, совпадут с нашими: все случайности зафиксированы через seed. Loss отдельного шага прыгает (это loss одного имени, а имена разной длины и сложности), поэтому смотрите на тренд, а итог оценивайте по mean_nll на всех именах.

МодельПараметровtrain NLLval NLL
Равномерная (ln⁡31\ln 31)03.433.43
Биграммы, без сглаживания (урок 4.1.2)9612.10∞
Биграммы, лучшее сглаживание α=0.3\alpha = 0.39612.202.50
Микро-GPT42561.662.51

На обучающих именах GPT заметно лучше биграмм: NLL 1.66 против 2.10, то есть perplexity e1.66≈5.3e^{1.66} \approx 5.3 вместо 8.2. Модель видит не одну предыдущую букву, а всё имя целиком. Но на отложенных именах результат тот же, что у биграмм: 2.51 против 2.50. Модель на 4256 параметров подстроилась под свои 218 обучающих имён (это всего 1475 предсказаний): их она знает гораздо лучше, чем новые. Это переобучениепереобучениеEN overfitting. Среди сгенерированных имён есть и новые («самина», «милар»), и обучающие («алина», «сава»).

Вывод не «трансформер плох», а «трансформеру нужны данные». На полутора тысячах символов простая модель с сильным допущением (смотреть только на одну букву назад) не уступает гибкой. На миллионе символов в следующем уроке расклад будет совсем другим. Как бороться с переобучением и следить за val loss — в уроке 4.6.3.

Есть (как у больших моделей)Нет (а у больших есть)
Эмбеддинги токенов и позицийBPE-токенизатор (у нас — буквы)
Многоголовое причинное вниманиеБатчей: одно имя за шаг
Pre-Norm, RMSNorm, остаточные связиОбучаемого множителя в RMSNorm, финальной нормы перед головой
MLP с расширением в 4 разаSwiGLU вместо ReLU (урок 4.5.3)
LM-голова, cross-entropyсвязывание весовсвязывание весов (общие эмбеддинги входа и выхода)EN weight tying (tied embeddings)Одна и та же матрица служит эмбеддингом токенов и LM head. Её градиент — сумма вкладов от обоих мест. В Hugging Face — флаг tie_word_embeddings. (следующий урок)
Backprop и AdamWarmup, клиппинга градиентовобрезка градиента (gradient clipping)EN gradient clippingЕсли норма градиента больше порога, градиент масштабируют до порога: clip_grad_norm_(params, 1.0). Стандарт в обучении LLM. (урок 4.6.3)
KV-кэшRoPE вместо обучаемых позиций
Сэмплирование с температуройТензоров, GPU, смешанной точности

Главное различие — скорость. На имя «анна» (5 позиций) прямой проход строит граф примерно из 43 тысяч узлов Value, на «александра» — из 93 тысяч. Каждый узел — отдельный объект Python, каждое сложение — вызов функции. PyTorch делает то же самое двумя десятками тензорных операций, каждая из которых обрабатывает тысячи чисел одной командой процессора или видеокарты. Отсюда разница в миллионы раз.

Именно это имеет в виду Карпатый: всё, что добавляют PyTorch, CUDA, FlashAttention, BF16 и распределённое обучение, — эффективность. Математика остаётся той, что вы видите в этом файле. Если вы понимаете каждую строку микро-GPT, вы понимаете, что делает DeepSeek-V3 с каждым токеном, только у него 61 слой вместо одного, ширина 7168 вместо 16 и 671 млрд параметров вместо четырёх тысяч.

Соберите блок трансформера из готовых частей и запустите обучение. В заготовке уже есть Value, linear, softmax, rmsnorm, функция attention (все головы для одного токена по кэшу), Adam и gpt, которая вызывает ваш блок. Модель ещё меньше, чем в тексте: d=8d = 8, две головы, 1360 параметров, чтобы обучение шло прямо в браузере.

Напишите три функции:

  • block — блок трансформера для одного токена (шаг 5);
  • name_loss — средний NLL по позициям имени (шаг 6);
  • train — цикл обучения: loss → обнулить градиенты → backward → шаг Adam.

Тесты сверят ваш блок с эталоном, который считает всё на обычных числах без кэша, полным пересчётом: если логиты совпали до 10−910^{-9}, вы собрали GPT правильно. Кнопка «Запустить» обучит модель 20 шагов (loss падает примерно с 3.4 до 3.2). Поставьте steps=300 и подождите от полуминуты до минуты (Python в браузере медленнее обычного) — модель начнёт выдавать похожие на имена слова вроде «лиса», «анта» или «када».

Собери микро-GPT и обучи её6 тестов
# Микро-GPT целиком в одном файле. Готовые части — сверху, ваши три функции — в разделе «ВАША ЧАСТЬ»
# (block, name_loss, train). Внизу — запуск: обучение на 20 шагов и генерация имён.
import math
import random

# ════════════════════════ ГОТОВЫЕ ЧАСТИ (менять не нужно) ════════════════════════

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

# Токенизатор: символ = токен, «.» (номер 0) — начало и конец имени
CHARS = sorted(set("".join(NAMES)))
STOI = {ch: i + 1 for i, ch in enumerate(CHARS)}
STOI["."] = 0
ITOS = {i: ch for ch, i in STOI.items()}
VOCAB = len(STOI)                       # 31

# Размеры крошечной модели
N_EMBD, N_HEAD, N_LAYER, BLOCK_SIZE = 8, 2, 1, 12
HEAD_DIM = N_EMBD // N_HEAD


class Value:
    """Автоград из модуля 3.2: число + локальные производные по «детям»."""

    __slots__ = ("data", "grad", "_children", "_local_grads")

    def __init__(self, data, children=(), local_grads=()):
        self.data = data
        self.grad = 0.0
        self._children = children
        self._local_grads = local_grads

    def __repr__(self):
        return f"Value({self.data:.4f})"

    def __add__(self, other):
        if isinstance(other, Value):
            return Value(self.data + other.data, (self, other), (1.0, 1.0))
        return Value(self.data + other, (self,), (1.0,))

    def __mul__(self, other):
        if isinstance(other, Value):
            return Value(self.data * other.data, (self, other), (other.data, self.data))
        return Value(self.data * other, (self,), (other,))

    def __pow__(self, k):
        return Value(self.data**k, (self,), (k * self.data ** (k - 1),))

    def exp(self):
        e = math.exp(self.data)
        return Value(e, (self,), (e,))

    def log(self):
        return Value(math.log(self.data), (self,), (1.0 / self.data,))

    def relu(self):
        return Value(max(0.0, self.data), (self,), (float(self.data > 0),))

    __radd__ = __add__
    __rmul__ = __mul__

    def __neg__(self):
        return self * -1.0

    def __sub__(self, other):
        return self + (-other)

    def __truediv__(self, other):
        return self * other**-1

    def backward(self):
        topo, seen, stack = [], set(), [(self, False)]
        while stack:                          # топологическая сортировка без рекурсии
            v, ready = stack.pop()
            if ready:
                topo.append(v)
            elif v not in seen:
                seen.add(v)
                stack.append((v, True))
                stack.extend((c, False) for c in v._children)
        self.grad = 1.0
        for v in reversed(topo):              # цепное правило от выхода к листьям
            for child, local in zip(v._children, v._local_grads):
                child.grad += local * v.grad


def init_params(seed=0):
    """Словарь матриц (списки строк из Value). Одинаковый seed → одинаковая модель."""
    rng = random.Random(seed)

    def matrix(n_out, n_in, std=0.08):
        return [[Value(rng.gauss(0, std)) for _ in range(n_in)] for _ in range(n_out)]

    P = {"wte": matrix(VOCAB, N_EMBD), "wpe": matrix(BLOCK_SIZE, N_EMBD), "lm_head": matrix(VOCAB, N_EMBD)}
    for i in range(N_LAYER):
        P[f"{i}.wq"], P[f"{i}.wk"] = matrix(N_EMBD, N_EMBD), matrix(N_EMBD, N_EMBD)
        P[f"{i}.wv"], P[f"{i}.wo"] = matrix(N_EMBD, N_EMBD), matrix(N_EMBD, N_EMBD)
        P[f"{i}.fc1"], P[f"{i}.fc2"] = matrix(4 * N_EMBD, N_EMBD), matrix(N_EMBD, 4 * N_EMBD)
    return P


def parameters(P):
    return [p for m in P.values() for row in m for p in row]


def linear(x, w):
    """y = W·x: w — список строк, x — список чисел (Value)."""
    return [sum(wi * xi for wi, xi in zip(row, x)) for row in w]


def softmax(logits):
    m = max(v.data for v in logits)
    exps = [(v - m).exp() for v in logits]
    total = sum(exps)
    return [e / total for e in exps]


def rmsnorm(x):
    ms = sum(xi * xi for xi in x) / len(x)
    return [xi * (ms + 1e-5) ** -0.5 for xi in x]


def attention(q, keys, values):
    """Многоголовое внимание ОДНОГО (текущего) токена к ключам и значениям всех токенов в кэше.

    q — список из N_EMBD чисел; keys, values — списки таких списков (по одному на токен).
    Возвращает список из N_EMBD чисел: склеенные выходы N_HEAD голов (до W_O).
    """
    out = []
    for h in range(N_HEAD):
        s = slice(h * HEAD_DIM, (h + 1) * HEAD_DIM)
        scores = [sum(a * b for a, b in zip(q[s], k[s])) / math.sqrt(HEAD_DIM) for k in keys]
        w = softmax(scores)
        out += [sum(w[t] * values[t][s][j] for t in range(len(w))) for j in range(HEAD_DIM)]
    return out


class Adam:
    """Adam из урока 1.4 для списка Value."""

    def __init__(self, params, lr=0.01, b1=0.85, b2=0.99, eps=1e-8):
        self.params, self.lr, self.b1, self.b2, self.eps = params, lr, b1, b2, eps
        self.m = [0.0] * len(params)
        self.v = [0.0] * len(params)
        self.t = 0

    def zero_grad(self):
        for p in self.params:
            p.grad = 0.0

    def step(self):
        self.t += 1
        for i, p in enumerate(self.params):
            self.m[i] = self.b1 * self.m[i] + (1 - self.b1) * p.grad
            self.v[i] = self.b2 * self.v[i] + (1 - self.b2) * p.grad**2
            m_hat = self.m[i] / (1 - self.b1**self.t)
            v_hat = self.v[i] / (1 - self.b2**self.t)
            p.data -= self.lr * m_hat / (v_hat**0.5 + self.eps)


def gpt(P, token, pos, keys, values):
    """Логиты следующего токена. keys[li], values[li] — кэш слоя li (пополняется внутри block)."""
    x = [t + p for t, p in zip(P["wte"][token], P["wpe"][pos])]
    x = rmsnorm(x)
    for li in range(N_LAYER):
        x = block(x, P, li, keys, values)
    return linear(x, P["lm_head"])


def new_cache():
    return [[] for _ in range(N_LAYER)], [[] for _ in range(N_LAYER)]


# ════════════════════════════════ ВАША ЧАСТЬ ════════════════════════════════════


def block(x, P, li, keys, values):
    """Блок трансформера для ОДНОГО токена (pre-norm, как в GPT-2 и Llama).

    x — список из N_EMBD чисел (Value); li — номер слоя; keys[li], values[li] — кэш этого слоя.
    Возвращает новый список из N_EMBD чисел.

    1) Внимание:  h = rmsnorm(x);  q = linear(h, P[f"{li}.wq"]);
       ключ linear(h, P[f"{li}.wk"]) и значение linear(h, P[f"{li}.wv"]) ДОБАВЬТЕ в keys[li] и values[li];
       a = attention(q, keys[li], values[li]);  x = linear(a, P[f"{li}.wo"]) + x  (поэлементно).
    2) MLP:  h = rmsnorm(x);  h = linear(h, P[f"{li}.fc1"]), к каждому элементу .relu();
       x = linear(h, P[f"{li}.fc2"]) + x  (поэлементно).
    """
    # TODO: ваш код
    raise NotImplementedError("block")


def name_loss(P, name):
    """Средний NLL всех предсказаний в имени (Value).

    «ян» → токены [0, я, н, 0]: по «.» предсказать «я», по «я» — «н», по «н» — «.».
    Позиций n = min(BLOCK_SIZE, len(токены) - 1). Кэш одного имени: keys, values = new_cache().
    На позиции pos: probs = softmax(gpt(P, токен, pos, keys, values)); loss = -log(probs[следующий токен]).
    """
    # TODO: ваш код
    raise NotImplementedError("name_loss")


def train(P, names, steps, lr=0.01):
    """steps шагов Adam; на шаге i учимся на имени names[i % len(names)].

    Один шаг: loss = name_loss(...) → обнулить градиенты → loss.backward() → шаг оптимизатора.
    Возвращает список значений loss (обычные числа, loss.data) длины steps.
    """
    # TODO: ваш код (оптимизатор: opt = Adam(parameters(P), lr=lr))
    raise NotImplementedError("train")


# ═══════════════════════ Запуск: обучение на несколько шагов ═══════════════════════


def sample(P, rng, temperature=0.8):
    keys, values = new_cache()
    token, out = 0, ""
    for pos in range(BLOCK_SIZE):
        probs = softmax([z * (1 / temperature) for z in gpt(P, token, pos, keys, values)])
        token = rng.choices(range(VOCAB), weights=[p.data for p in probs])[0]
        if token == 0:
            break
        out += ITOS[token]
    return out


try:
    P = init_params(seed=0)
    print("параметров:", len(parameters(P)))
    print(f"loss до обучения: {name_loss(P, 'анна').data:.3f}  (ln 31 = {math.log(VOCAB):.3f})")
    names = NAMES[:]
    random.Random(0).shuffle(names)
    hist = train(P, names, steps=20)          # хотите имена получше — поставьте 300 (≈ полминуты)
    for i in range(0, len(hist), 10):
        print(f"шаги {i:2d}–{i + 9:2d}: средний loss {sum(hist[i:i + 10]) / 10:.3f}")
    rng = random.Random(1)
    print("имена:", " ".join(sample(P, rng) for _ in range(6)))
except NotImplementedError as e:
    print("Сначала допишите функцию:", e)

Перед любым обучением сравните loss свежей модели с ln⁡V\ln V. У микро-GPT это ln⁡31≈3.43\ln 31 \approx 3.43, и в выводе выше первый шаг дал 3.36. Если бы вы увидели 10 или 0.5 — ошибка в softmax, в сдвиге целевых токенов или в инициализации. Этот приём из урока 3.3.3 работает для моделей любого размера: у Qwen3 со словарём 151 936 токенов свежая модель стартует с ln⁡151 936≈11.9\ln 151\,936 \approx 11.9.

Проверь себя6 вопросов
1 Сколько параметров у микро-GPT со словарём 31, шириной d=8d = 8, 12 позициями и одним слоем (как в задании)?
2 Почему в функции gpt нет причинной маски, но токен всё равно не видит будущее?
3 Свежая микро-GPT со словарём 31 токен. Какой loss ожидать на первом шаге? Округлите до двух знаков.
4 Микро-GPT: train NLL 1.66, val NLL 2.51; биграммы: train 2.20, val 2.50. Какие выводы верны?Можно выбрать несколько ответов
5 Что делает остаточная связь x = attn(x) + x?
6 Сколько обучающих примеров (предсказаний) даёт имя «ярослава» в микро-GPT?
  • GPT — это эмбеддинги токена и позиции, стопка блоков (внимание + MLP с нормализацией и остаточными связями), LM-голова и cross-entropy. Всё это помещается примерно в 150 строк чистого Python.
  • Если подавать токены по одному и хранить ключи и значения прошлых токенов в списках, причинная маска не нужна: это KV-кэш в чистом виде.
  • Параметры считаются вручную: 12d212d^2 на слой плюс эмбеддинги. Loss свежей модели ≈ ln⁡V\ln V.
  • На 218 именах микро-GPT переобучается: train NLL 1.66, но val 2.51, как у биграмм. Гибкой модели нужны данные.
  • Тензоры, батчи и GPU не меняют алгоритм — они делают его в миллионы раз быстрее.

Дальше: переписываем ту же GPT на PyTorch в стиле nanoGPT, добавляем weight tying и обучаем её на миллионе символов русского текста — GPT на PyTorch.