Микро-GPT на чистом Python
Собираем полную GPT — токенизатор, автоград, внимание, MLP, обучение и генерацию — примерно в 150 строк чистого Python и обучаем её на русских именах прямо в браузере.
Зачем это нужно
Заголовок раздела «Зачем это нужно»За последние модули вы собрали все детали языковой модели по отдельности: биграммную, 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:
- Токенизатор — текст в номера.
- Эмбеддинги — номер токена и номер позиции в векторы, которые складываются.
- Блоки трансформера — внимание (токен собирает информацию из прошлого) и MLP (токен её обрабатывает).
- LM-головаLM-голова (языковая голова)EN LM head (language modeling head)Последний линейный слой GPT: превращает вектор ширины d в V логитов, по одному на токен словаря. Часто делит матрицу с эмбеддингами (weight tying). — вектор в логитылогитEN logitСтрого — логарифм шансов log(p / (1 − p)). В нейросетях логитами называют любые выходы до softmax или сигмоиды., по одному числу на каждый токен словаря.
- Loss и оптимизатор — перекрёстная энтропияперекрёстная энтропияEN cross-entropy и Adam.
Разберём их по шагам. Все фрагменты кода ниже — части одного файла micro_gpt.py; склейте их по порядку, и он запустится.
Шаг 1. Данные и токенизатор
Заголовок раздела «Шаг 1. Данные и токенизатор»Берём 242 имени из урока 4.1.2: положите рядом файл names.py со списком NAMES из
биграммного урока. Порядок имён важен: от него зависит, какие 24 имени
попадут в валидацию. Токенизатор — посимвольныйпосимвольная токенизацияEN character-level tokenizationКаждый символ — отдельный токен. Словарь крошечный (десятки–сотни), зато последовательности длинные. Удобна для учебных моделей; в LLM вместо неё byte-level BPE.: каждая буква — отдельный
токен, а точка . с номером 0 означает и начало, и конец имени. В именах встречаются 30 разных букв, значит
словарь .
import mathimport randomimport 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…30stoi["."] = 0 # 0 — начало и конец имениitos = {i: ch for ch, i in stoi.items()}V = len(stoi) # 31Имя «анна» превращается в [0, 1, 14, 14, 1, 0]. Из этой последовательности получается пять обучающих
примеров: по «.» предсказать «а», по «.а» — «н», по «.ан» — «н», по «.анн» — «а», по «.анна» — «.».
Модель учится на всех позициях сразу, как и большие LLM.
Шаг 2. Автоград
Заголовок раздела «Шаг 2. Автоград»Это 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Шаг 3. Параметры
Заголовок раздела «Шаг 3. Параметры»Параметры — это матрицы, а матрица у нас — список строк из Value. Размеры модели крошечные: ширина ,
4 головы по 4 числа, один слой, до 12 позиций (самое длинное имя — 10 букв, плюс точка в начале).
n_embd, n_head, n_layer, block_size = 16, 4, 1, 12head_dim = n_embd // n_headrandom.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))Посчитаем вручную, это полезная привычка:
| Матрица | Форма | Параметров | Что делает |
|---|---|---|---|
wte | 496 | эмбеддинг токена | |
wpe | 192 | эмбеддинг позицииобучаемые позиционные эмбеддингиEN learned (absolute) position embeddingsТаблица векторов по одному на позицию, обучается вместе с моделью (BERT — 512 позиций, GPT-2 — 1024). За пределами таблицы позиций просто нет. | |
wq, wk, wv, wo | 4 × | 1024 | внимание, |
fc1, fc2 | и | 2048 | MLP, |
lm_head | 496 | логиты | |
| Итого | 4256 |
Запомните пропорцию: в блоке на внимание приходится , на MLP — , всего на слой. Эта формула работает и для GPT-2, и для GPT-3: у GPT-3 175B млрд параметров в блоках.
Шаг 4. Кирпичики: linear, softmax, RMSNorm
Заголовок раздела «Шаг 4. Кирпичики: linear, softmax, RMSNorm»Три функции, из которых собрано всё остальное:
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. приводит вектор к единичной «громкости»:
Пример: . Среднее квадратов , корень , результат . Направление вектора то же, а средний квадрат координат стал равен 1. Это не даёт активациям разрастаться от слоя к слою (подробно — в уроке 3.4.1). В настоящих моделях у RMSNorm есть ещё обучаемый множитель ; мы его опустили.
Шаг 5. Модель: один токен за вызов
Заголовок раздела «Шаг 5. Модель: один токен за вызов»Самая интересная часть. Функция 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 (токен «н»):
- Эмбеддинги. Строка 14 таблицы
wte(буква «н») плюс строка 2 таблицыwpe. Получается вектор из 16 чисел: «какая буква» и «где она стоит» в одном векторе. Его сразу пропускаем через RMSNorm, чтобы в блок пришёл вектор привычного масштаба. - Внимание. Из нормированного вектора делаем запрос , ключ и значение (урок 4.4.2). Ключ и значение дописываем в списки: теперь там три записи — для «.», «а» и «н». Каждая голова сравнивает свой кусок (4 числа) с кусками трёх ключей, делит на , берёт softmax и усредняет значения.
- Остаточная связьостаточная связьEN residual connection. Результат внимания после прибавляется к входу, а не заменяет его. Это остаточный потокостаточный потокEN residual streamВектор шириной d, который течёт сквозь все блоки. Каждый подслой читает из него и дописывает поправку, так что выход = вход + сумма всех поправок.: каждый подслой дописывает в него поправку.
- MLP. Норма, расширение , ReLUReLU (выпрямитель)EN rectified linear unit (ReLU)ReLU(z) = max(0, z). Дешёвая, не насыщается при z > 0; минус — «мёртвые» нейроны, у которых z < 0 на всех примерах., сжатие , снова прибавляем к потоку. Если внимание — «собрать информацию у соседей», то MLP — «подумать над собранным» в одиночку.
- LM-голова. Матрица превращает вектор в 31 логит, по одному на каждую букву и точку.
Где причинная маска?
Заголовок раздела «Где причинная маска?»В уроке 4.4.2 мы ставили на будущие позиции, чтобы токен не
подсматривал. Здесь маски нет, и она не нужна: токен «н» на позиции 2 видит только то, что уже лежит в keys —
три записи. Ключа следующей «а» там просто ещё нет: её ключ появится, когда мы вызовем gpt для позиции 3.
Эти списки и есть KV-кэшKV-кэшEN KV cache: ключи и значения прошлых токенов вычисляются один раз и хранятся. В PyTorch-версии мы будем обрабатывать все позиции сразу матрицами и вернём маску. А KV-кэш вернётся в уроке 4.6.4, где он ускорит генерацию.
Шаг 6. Loss и обучение
Заголовок раздела «Шаг 6. Loss и обучение»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.2steps, lr, b1, b2 = 2000, 0.01, 0.85, 0.99m = [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 NLL | val NLL |
|---|---|---|---|
| Равномерная () | 0 | 3.43 | 3.43 |
| Биграммы, без сглаживания (урок 4.1.2) | 961 | 2.10 | ∞ |
| Биграммы, лучшее сглаживание | 961 | 2.20 | 2.50 |
| Микро-GPT | 4256 | 1.66 | 2.51 |
На обучающих именах GPT заметно лучше биграмм: NLL 1.66 против 2.10, то есть perplexity вместо 8.2. Модель видит не одну предыдущую букву, а всё имя целиком. Но на отложенных именах результат тот же, что у биграмм: 2.51 против 2.50. Модель на 4256 параметров подстроилась под свои 218 обучающих имён (это всего 1475 предсказаний): их она знает гораздо лучше, чем новые. Это переобучениепереобучениеEN overfitting. Среди сгенерированных имён есть и новые («самина», «милар»), и обучающие («алина», «сава»).
Вывод не «трансформер плох», а «трансформеру нужны данные». На полутора тысячах символов простая модель с сильным допущением (смотреть только на одну букву назад) не уступает гибкой. На миллионе символов в следующем уроке расклад будет совсем другим. Как бороться с переобучением и следить за val loss — в уроке 4.6.3.
Что есть в микро-GPT и чего нет
Заголовок раздела «Что есть в микро-GPT и чего нет»| Есть (как у больших моделей) | Нет (а у больших есть) |
|---|---|
| Эмбеддинги токенов и позиций | 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 и Adam | Warmup, клиппинга градиентовобрезка градиента (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, которая вызывает
ваш блок. Модель ещё меньше, чем в тексте: , две головы, 1360 параметров, чтобы обучение шло прямо
в браузере.
Напишите три функции:
block— блок трансформера для одного токена (шаг 5);name_loss— средний NLL по позициям имени (шаг 6);train— цикл обучения: loss → обнулить градиенты → backward → шаг Adam.
Тесты сверят ваш блок с эталоном, который считает всё на обычных числах без кэша, полным пересчётом: если
логиты совпали до , вы собрали GPT правильно. Кнопка «Запустить» обучит модель 20 шагов (loss падает
примерно с 3.4 до 3.2). Поставьте steps=300 и подождите от полуминуты до минуты (Python в браузере медленнее
обычного) — модель начнёт выдавать похожие на имена слова вроде «лиса», «анта» или «када».
# Микро-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 свежей модели с . У микро-GPT это , и в выводе выше первый шаг дал 3.36. Если бы вы увидели 10 или 0.5 — ошибка в softmax, в сдвиге целевых токенов или в инициализации. Этот приём из урока 3.3.3 работает для моделей любого размера: у Qwen3 со словарём 151 936 токенов свежая модель стартует с .
Проверь себя
Заголовок раздела «Проверь себя»- GPT — это эмбеддинги токена и позиции, стопка блоков (внимание + MLP с нормализацией и остаточными связями), LM-голова и cross-entropy. Всё это помещается примерно в 150 строк чистого Python.
- Если подавать токены по одному и хранить ключи и значения прошлых токенов в списках, причинная маска не нужна: это KV-кэш в чистом виде.
- Параметры считаются вручную: на слой плюс эмбеддинги. Loss свежей модели ≈ .
- На 218 именах микро-GPT переобучается: train NLL 1.66, но val 2.51, как у биграмм. Гибкой модели нужны данные.
- Тензоры, батчи и GPU не меняют алгоритм — они делают его в миллионы раз быстрее.
Дальше: переписываем ту же GPT на PyTorch в стиле nanoGPT, добавляем weight tying и обучаем её на миллионе символов русского текста — GPT на PyTorch.