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

GPT на PyTorch

Пошагово собираем GPT в стиле nanoGPT — эмбеддинги, блоки, LM-голову со связанными весами, — обучаем её на миллионе символов русского текста прямо на процессоре и генерируем текст.

УРОКstage-4/4-6-gpt-from-scratch/02-nanogpt-pytorch.mdx2 ч 30 минУрок 2 из 5
После урока вы сможете
  • Собрать GPT в стиле nanoGPT на PyTorch и назвать форму тензора после каждого шага
  • Объяснить weight tying и посчитать, сколько параметров он экономит у GPT-2 и Qwen
  • Проверить модель до обучения — число параметров по формуле 12Ld212Ld^2 и стартовый loss ln⁡V\ln V
  • Обучить символьную GPT на русском корпусе на CPU и сгенерировать текст

Микро-GPT обучалась на 218 именах 13 минут и строила граф из 43 тысяч объектов Python на одно короткое имя. На миллионе символов она считала бы неделями. При этом сама модель была правильной: все части GPT на месте. Не хватало только скорости.

Теперь перепишем ту же GPT на PyTorch. Математика не изменится, изменится форма вычислений:

  • вместо списков из Value — тензоры: одна операция обрабатывает тысячи чисел;
  • вместо одного имени за шаг — батч из 32 окон текста по 128 символов, то есть 4096 предсказаний за проход;
  • вместо токенов по одному — все позиции сразу, а будущее закрывает причинная маска;
  • вместо нашего автограда — loss.backward() из PyTorch (урок 3.3.1).

Образец — nanoGPT Андрея Карпатого: компактная учебная реализация GPT-2 (модель около 300 строк). В ноябре 2025 года автор пометил репозиторий как устаревший и отправляет читателей к nanochat, но как учебник архитектуры GPT-2 nanoGPT по-прежнему один из лучших. Наша model.py повторяет его структуру и имена слоёв, поэтому потом вы сможете читать оригинал и даже загрузить в эту архитектуру веса настоящей GPT-2.

Архитектура — классическая GPT-2: LayerNorm, GELU, обучаемые позиции, смещения в линейных слоях. Как от неё перейти к блоку Llama и Qwen (RMSNorm, RoPE, SwiGLU, GQA), разобрано в уроке 4.5.3 и пригодится в проекте модуля 5.6.

Нужен русский текст на 1–3 МБ. Подойдёт любой, но интереснее всего классика в общественном достоянии.

Вариант А: Пушкин или сказки. Скопируйте с Викитеки «Сказку о царе Салтане», «Руслана и Людмилу», «Евгения Онегина», народные сказки — всё, что найдёте, в один файл input.txt в кодировке UTF-8. Чем больше текста, тем лучше: миллион символов — хороший минимум.

Вариант Б: тексты этого курса. Если Пушкина под рукой нет, обучите модель на русской прозе уроков ChinaAI. Они уже лежат в вашей копии репозитория. Скрипт собирает из MDX-файлов обычные абзацы, выбрасывая код, формулы и разметку. Он держится на регулярных выражениях (re.sub(шаблон, замена, текст); азбуку шаблонов мы разбирали в уроке 4.2.2). Вникать в каждый шаблон не обязательно — это вспомогательный инструмент, достаточно его запустить:

"""prepare_corpus.py — русские тексты уроков курса → input.txt. Запуск из корня репозитория."""
import pathlib
import re
DOCS = pathlib.Path("site/src/content/docs")
lines = []
for f in sorted(DOCS.rglob("*.mdx")):
if "translations" in f.parts: # переводы первоисточников — не уроки курса, пропускаем
continue
src = re.sub(r"\A---.*?\n---\n", "", f.read_text(encoding="utf-8"), flags=re.S) # front-matter
src = re.sub(r"```.*?```", "", src, flags=re.S) # блоки кода
for s in (line.strip() for line in src.splitlines()):
if not s or s[0] in "<{|$#:" or re.match(r"^\w+:", s): # компоненты, таблицы, «ключ: значение»
continue
s = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", s) # [текст](ссылка) → текст
s = re.sub(r"`[^`]*`|\$[^$]*\$|<[^>]*>|\*\*", "", s) # код, формулы, теги, жирный
if sum("а" <= ch.lower() <= "я" for ch in s) >= 40: # оставляем только строки-прозу
lines.append(s.lstrip("- "))
text = "\n".join(lines)
pathlib.Path("input.txt").write_text(text, encoding="utf-8")
print(f"{len(text):,} символов".replace(",", " "))

Когда мы писали урок, получилось 890 тысяч символов. Все числа ниже — для этого корпуса. Курс растёт, поэтому у вас текста будет в несколько раз больше (при последней проверке — около 2.3 млн символов), а на Пушкине числа будут совсем другими. Ход обучения от этого не меняется. Модель, обученная на учебнике по LLM, начнёт писать фразы про модели и токены. Это наглядно показывает: языковая модель — сжатая статистика своего корпуса.

Как и в микро-GPT, токен — один символ. Словарь строим по корпусу, предварительно выбросив редкие символы (иероглифы, эмодзи, ударения), которые только раздувают словарь:

"""train_basic.py — данные, батчи, простой цикл обучения и генерация. python train_basic.py"""
import math
import os
import time
import torch
from model import GPT, GPTConfig
torch.manual_seed(1337)
torch.set_num_threads(int(os.environ.get("THREADS", min(8, os.cpu_count())))) # потоков CPU: подберите сами
# ── Данные: символ = токен ──────────────────────────────────────────────────
RU = "абвгдеёжзийклмнопрстуфхцчшщъыьэюя"
EN = "abcdefghijklmnopqrstuvwxyz"
ALLOWED = set(RU + RU.upper() + EN + EN.upper() + "0123456789" + " \n.,:;!?-—«»()")
raw = open("input.txt", encoding="utf-8").read()
text = "".join(ch for ch in raw if ch in ALLOWED) # выбрасываем редкие символы
chars = sorted(set(text))
stoi = {ch: i for i, ch in enumerate(chars)}
itos = {i: ch for i, ch in enumerate(chars)}
encode = lambda s: [stoi[c] for c in s]
decode = lambda ids: "".join(itos[i] for i in ids)
data = torch.tensor(encode(text), dtype=torch.long)
n = int(0.9 * len(data))
train_data, val_data = data[:n], data[n:] # последние 10% — валидация
print(f"символов: {len(data):,}".replace(",", " ") + f", словарь: {len(chars)}")

Весь корпус — один длинный тензор чисел. Последние 10% откладываем для валидации: на них модель не учится, по ним мы честно меряем качество. Почему последние, а не случайные 10%, — в следующем уроке.

Обучающий пример — окно из block_size символов, а правильные ответы — то же окно, сдвинутое на один символ вправо. Возьмём строку «Мороз и солнце; день чудесный!» и окно из 8 символов:

x = 'Мороз и ' y = 'ороз и с'

Одно окно — это сразу 8 предсказаний: по «М» угадать «о», по «Мо» — «р», и так до «Мороз и » → «с». Модель видит настоящий текст, а не свои предсказания, — это teacher forcingteacher forcing (обучение на верном префиксе)EN teacher forcingПри обучении модель на каждом шаге видит настоящий текст, а не свои предсказания. Поэтому все позиции можно обучать параллельно.. Батч — 32 случайных окна:

def get_batch(split, batch_size=32):
d = train_data if split == "train" else val_data
ix = torch.randint(len(d) - cfg.block_size - 1, (batch_size,)) # случайные начала окон
x = torch.stack([d[i : i + cfg.block_size] for i in ix])
y = torch.stack([d[i + 1 : i + 1 + cfg.block_size] for i in ix]) # те же окна со сдвигом на 1
return x, y

Формы: x и y — (32,128)(32, 128). Это 32⋅128=409632 \cdot 128 = 4096 предсказаний за один проход модели.

Создайте файл model.py. Начнём с конфигурации:

"""model.py — GPT в стиле nanoGPT (GPT-2): эмбеддинги, блоки, LM-голова, weight tying."""
import math
from dataclasses import dataclass
import torch
import torch.nn as nn
from torch.nn import functional as F
@dataclass
class GPTConfig:
vocab_size: int = 128
block_size: int = 128 # максимальная длина контекста
n_layer: int = 4
n_head: int = 4
n_embd: int = 128
dropout: float = 0.0

Это многоголовое внимание из урока 4.4.3 с одной оптимизацией из GPT-2: три матрицы WQ,WK,WVW_Q, W_K, W_V склеены в одну c_attn размера C×3CC \times 3C, а результат режется на три части. Одно большое умножение быстрее трёх маленьких.

class CausalSelfAttention(nn.Module):
def __init__(self, cfg):
super().__init__()
assert cfg.n_embd % cfg.n_head == 0
self.n_head = cfg.n_head
self.c_attn = nn.Linear(cfg.n_embd, 3 * cfg.n_embd) # W_Q, W_K, W_V одной матрицей
self.c_proj = nn.Linear(cfg.n_embd, cfg.n_embd) # W_O
self.dropout = cfg.dropout
def forward(self, x):
B, T, C = x.shape
q, k, v = self.c_attn(x).split(C, dim=2) # три тензора (B, T, C)
q = q.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, H, T, d_h)
k = k.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
v = v.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
# self.training — флаг nn.Module: True после model.train(), False после model.eval();
# dropout работает только при обучении
y = F.scaled_dot_product_attention(
q, k, v, is_causal=True, dropout_p=self.dropout if self.training else 0.0
)
# склеиваем головы; contiguous() — копия в «плотную» память, без неё view после transpose упадёт (урок 4.4.3)
y = y.transpose(1, 2).contiguous().view(B, T, C)
return self.c_proj(y)

F.scaled_dot_product_attention с is_causal=True считает softmax(QK⊤/dh+маска)V\mathrm{softmax}(QK^\top/\sqrt{d_h} + \text{маска})V одним вызовом и на GPU сам выбирает FlashAttention. В микро-GPT маска была не нужна: токены приходили по одному. Здесь все 128 позиций считаются одновременно, и без причинной маскипричинная (каузальная) маскаEN causal maskЗапрещает токену смотреть на будущие позиции — оценки выше диагонали заменяются на −∞ до softmax. Нужна, чтобы GPT-подобная модель училась предсказывать следующий токен на всех позициях сразу. позиция 5 видела бы ответ на позиции 6. Модель «научилась» бы списывать: train loss рухнул бы почти до нуля, а генерация была бы мусором.

class MLP(nn.Module):
def __init__(self, cfg):
super().__init__()
self.c_fc = nn.Linear(cfg.n_embd, 4 * cfg.n_embd) # расширяем в 4 раза
self.c_proj = nn.Linear(4 * cfg.n_embd, cfg.n_embd) # и сжимаем обратно
self.dropout = nn.Dropout(cfg.dropout)
def forward(self, x):
return self.dropout(self.c_proj(F.gelu(self.c_fc(x))))
class Block(nn.Module):
def __init__(self, cfg):
super().__init__()
self.ln_1 = nn.LayerNorm(cfg.n_embd)
self.attn = CausalSelfAttention(cfg)
self.ln_2 = nn.LayerNorm(cfg.n_embd)
self.mlp = MLP(cfg)
def forward(self, x):
x = x + self.attn(self.ln_1(x)) # pre-norm + остаточная связь
x = x + self.mlp(self.ln_2(x))
return x

Блок тот же, что в микро-GPT, с двумя заменами: LayerNormнормализация слояEN layer normalization вместо RMSNorm (у LayerNorm есть ещё вычитание среднего и обучаемые сдвиг и масштаб) и GELUGELUEN Gaussian error linear unit (GELU)GELU(z) = z·Φ(z), где Φ — функция распределения N(0, 1). Гладкая активация BERT и GPT-2 (в GPT-2 — tanh-приближение). вместо ReLU (гладкая версия, как в GPT-2). Схема Pre-NormPre-Norm (нормализация на входе подслоя)EN pre-normalization (Pre-LN)x + F(Norm(x)): нормализуется только вход ветки, остаточный путь чистый. Стандарт с GPT-2 (2019); требует финальной нормализации перед LM-головой. — нормализация перед подслоем, а остаточный поток не нормируется — стандарт современных LLM (урок 4.5.1).

class GPT(nn.Module):
def __init__(self, cfg):
super().__init__()
self.cfg = cfg
self.wte = nn.Embedding(cfg.vocab_size, cfg.n_embd) # эмбеддинги токенов
self.wpe = nn.Embedding(cfg.block_size, cfg.n_embd) # эмбеддинги позиций
self.drop = nn.Dropout(cfg.dropout)
self.blocks = nn.ModuleList([Block(cfg) for _ in range(cfg.n_layer)])
self.ln_f = nn.LayerNorm(cfg.n_embd) # финальная нормализация
self.lm_head = nn.Linear(cfg.n_embd, cfg.vocab_size, bias=False)
self.wte.weight = self.lm_head.weight # weight tying: одна матрица на двоих
self.apply(self._init_weights) # вызвать _init_weights для каждого подмодуля
for name, p in self.named_parameters(): # приём GPT-2: тише выходы блоков
if name.endswith("c_proj.weight"):
nn.init.normal_(p, mean=0.0, std=0.02 / math.sqrt(2 * cfg.n_layer))
def _init_weights(self, module):
if isinstance(module, nn.Linear):
nn.init.normal_(module.weight, mean=0.0, std=0.02)
if module.bias is not None:
nn.init.zeros_(module.bias)
elif isinstance(module, nn.Embedding):
nn.init.normal_(module.weight, mean=0.0, std=0.02)
def forward(self, idx, targets=None):
B, T = idx.shape
assert T <= self.cfg.block_size, f"контекст {T} длиннее block_size"
pos = torch.arange(T, device=idx.device) # 0, 1, …, T-1
x = self.drop(self.wte(idx) + self.wpe(pos)) # (B, T, C)
for block in self.blocks:
x = block(x)
logits = self.lm_head(self.ln_f(x)) # (B, T, vocab)
loss = None
if targets is not None:
loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1))
return logits, loss
@torch.no_grad()
def generate(self, idx, max_new_tokens, temperature=1.0, top_k=None):
for _ in range(max_new_tokens):
idx_cond = idx[:, -self.cfg.block_size:] # не длиннее контекста
logits, _ = self(idx_cond)
logits = logits[:, -1, :] / temperature # только последняя позиция
if top_k is not None:
v, _ = torch.topk(logits, min(top_k, logits.size(-1))) # v: (B, k), по убыванию
# v[:, [-1]] — k-е по величине значение каждой строки, форма (B, 1), чтобы сработал broadcasting
logits[logits < v[:, [-1]]] = -float("inf")
probs = F.softmax(logits, dim=-1)
next_id = torch.multinomial(probs, num_samples=1) # сэмплируем (урок 4.1.3)
idx = torch.cat([idx, next_id], dim=1) # дописываем токен в конец: авторегрессия
return idx

Пройдём по forward с формами для батча B=32B = 32, T=128T = 128, C=128C = 128, V=137V = 137:

flowchart LR
A["idx<br/>(32, 128)"] --> B["wte(idx) + wpe(pos)<br/>(32, 128, 128)"]
B --> C["4 блока<br/>(32, 128, 128)"]
C --> D["ln_f<br/>(32, 128, 128)"]
D --> E["lm_head<br/>(32, 128, 137)"]
E --> F["cross_entropy по<br/>4096 позициям"]
Формы тензоров в forward. Позиции pos = 0…127 транслируются на весь батч.

Детали, которых не было в микро-GPT:

  • ln_f — финальная нормализация перед головой. Остаточный поток к концу сети накапливает поправки всех блоков, и его масштаб может быть любым. LM-голове удобнее работать с нормированным вектором.
  • F.cross_entropy с view(-1, V) — функция ждёт матрицу «примеры × классы», поэтому (32,128,137)(32, 128, 137) превращаем в (4096,137)(4096, 137), а цели — в вектор из 4096 чисел. Loss — среднее по всем позициям.
  • Инициализация. Все веса — нормальное распределение со стандартным отклонением 0.02, как в GPT-2. А у c_proj (последней матрицы внимания и MLP) ещё в 2L\sqrt{2L} раз меньше. Причина: каждый из 2L2L подслоёв прибавляет свой выход к остаточному потоку, и дисперсия суммы растёт с числом слагаемых. Поправка держит масштаб потока примерно постоянным при любой глубине. Этот приём описан в статье GPT-2.
  • generate берёт логиты только последней позиции (она предсказывает следующий токен) и обрезает контекст до block_size: позиционных эмбеддингов ровно столько, дальше модель позиций не знает.

Строка self.wte.weight = self.lm_head.weight делает эмбеддинги токенов и LM-голову одним и тем же тензором. Это связывание весовсвязывание весов (общие эмбеддинги входа и выхода)EN weight tying (tied embeddings)Одна и та же матрица служит эмбеддингом токенов и LM head. Её градиент — сумма вкладов от обоих мест. В Hugging Face — флаг tie_word_embeddings..

Почему это разумно. Матрица эмбеддингов EE размера V×dV \times d переводит токен в вектор: строка EiE_i — «смысл» токена ii. LM-голова делает обратное: логит токена ii — это скалярное произведение выхода сети hh со строкой WiW_i: logiti=h⋅Wi\text{logit}_i = h \cdot W_i. Если W=EW = E, то больший логит получает токен, чей эмбеддинг сильнее всего «совпадает по направлению» с выходом сети (скалярное произведение больше). Похожие токены и на входе, и на выходе получают похожие векторы, а это нам и нужно. Пресс и Вольф в 2016 году показали, что связывание уменьшает число параметров и обычно не ухудшает, а то и улучшает perplexity.

Градиент общей матрицы — сумма двух вкладов: от входа (через строки использованных токенов) и от выхода (через все строки сразу). PyTorch складывает их сам, потому что это один объект Parameter. А при подсчёте параметров model.parameters() выдаёт общий тензор один раз:

import torch
from model import GPT, GPTConfig
for name, cfg in [("наша модель", GPTConfig(vocab_size=137)),
("GPT-2 small", GPTConfig(vocab_size=50257, block_size=1024, n_layer=12, n_head=12, n_embd=768))]:
model = GPT(cfg)
tied = sum(p.numel() for p in model.parameters()) # общая матрица — один раз
untied = sum(p.numel() for _, p in model.named_parameters(remove_duplicate=False))
emb = cfg.vocab_size * cfg.n_embd
print(f"{name}: {tied:,} параметров; без связывания было бы {untied:,}; "
f"матрица V×d = {emb:,} ({emb / tied:.0%})".replace(",", " "))
print("одна и та же матрица:", model.wte.weight is model.lm_head.weight)
наша модель: 827 264 параметров; без связывания было бы 844 800; матрица V×d = 17 536 (2%)
GPT-2 small: 124 439 808 параметров; без связывания было бы 163 037 184; матрица V×d = 38 597 376 (31%)
одна и та же матрица: True

У нас словарь крошечный, и экономия — 2%. У GPT-2 small словарь 50 257 токенов, и матрица V×dV \times d — почти треть модели. Отсюда правило: связывание важно для маленьких моделей с большим словарём. У больших моделей матрица эмбеддингов — единицы процентов и меньше (у DeepSeek-V3 — около 0.1%), вторая копия стоит недорого, и обычно берут две независимые матрицы: так у входа и выхода больше свободы. Ниже, в китайском углу, видно, как ровно по этой границе решают Qwen и MiniMind.

Кстати, 124 439 808 — ровно число параметров настоящей GPT-2 small. Архитектура совпадает до последнего смещения, поэтому в нашу модель можно загрузить веса OpenAI (в nanoGPT для этого есть GPT.from_pretrained).

В одном блоке GPT-2 ширины dd:

СлойВесаСмещения
c_attn (Q, K, V)3d23d^23d3d
attn.c_projd2d^2dd
mlp.c_fc4d24d^24d4d
mlp.c_proj4d24d^2dd
ln_1, ln_2—4d4d
Блок12d212d^213d13d

Плюс эмбеддинги токенов VdVd (общие с головой), позиций TdTd и ln_f 2d2d. Для нашей модели (L=4L = 4, d=128d = 128, V=137V = 137, T=128T = 128):

4⋅(12⋅1282+13⋅128)+137⋅128+128⋅128+2⋅128=793 088+17 536+16 384+256=827 264\begin{aligned} &4 \cdot (12 \cdot 128^2 + 13 \cdot 128) + 137 \cdot 128 \\ &\quad {}+ 128 \cdot 128 + 2 \cdot 128 \\ &= 793\,088 + 17\,536 + 16\,384 + 256 \\ &= 827\,264 \end{aligned}

Ровно столько печатает скрипт. Если ваше число не сходится с формулой — где-то лишний или пропущенный слой. Если в вашем корпусе словарь не 137, а, скажем, 138 символов, параметров станет на 128 больше: у общей матрицы эмбеддингов и головы появится ещё одна строка.

Только что созданная модель выдаёт почти равномерное распределение, её loss должен быть около ln⁡137≈4.92\ln 137 \approx 4.92 (урок 1.3.2). Скрипт проверяет это сам (следующий фрагмент train_basic.py):

cfg = GPTConfig(vocab_size=len(chars), block_size=128, n_layer=4, n_head=4, n_embd=128)
model = GPT(cfg)
print(f"параметров: {sum(p.numel() for p in model.parameters()):,}".replace(",", " "))
@torch.no_grad() # декоратор: внутри функции градиенты не считаются
def estimate_loss(iters=20):
"""Средний loss по 20 случайным батчам из train и из val."""
model.eval() # режим оценки: выключает dropout
# model(*get_batch(s)) — то же, что x, y = get_batch(s); model(x, y). [1] — второй выход, loss
out = {s: torch.stack([model(*get_batch(s))[1] for _ in range(iters)]).mean().item() for s in ("train", "val")}
model.train() # обратно в режим обучения
return out
print(f"loss до обучения: {estimate_loss()['train']:.3f}, ln V = {math.log(len(chars)):.3f}")

(В файле функцию get_batch из шага 2 поставьте перед этим фрагментом.) У нас: loss до обучения: 4.922, ln V = 4.920. Совпадение до сотых — заслуга маленькой инициализации 0.02: логиты почти нулевые, softmax почти равномерный. Мы проверили, что будет без _init_weights. Со стандартной инициализацией nn.Linear стартовый loss около 5.1 — терпимо. А если общей матрице достанется стандартная инициализация nn.Embedding, то есть N(0,1)\mathcal{N}(0, 1), логиты получаются огромными, и loss на старте около 89: модель с первого шага «уверенно ошибается», и первые сотни шагов уходят на то, чтобы просто сбить эту уверенность.

Самый простой цикл: AdamWAdamWEN AdamW (Adam with decoupled weight decay)Adam, в котором затухание весов применяется отдельно от адаптивного шага. Оптимизатор по умолчанию для обучения LLM. с постоянным learning rate 10−310^{-3}, 2000 шагов. Раз в 500 шагов меряем loss на 20 батчах из train и из val.

optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)
max_steps = 2000
t0 = time.time()
for step in range(1, max_steps + 1):
xb, yb = get_batch("train")
_, loss = model(xb, yb) # прямой проход
optimizer.zero_grad(set_to_none=True) # обнуляем старые градиенты
loss.backward() # обратный проход
optimizer.step() # шаг AdamW
if step % 500 == 0 or step == max_steps:
ls = estimate_loss()
print(f"шаг {step:5d} train {ls['train']:.3f} val {ls['val']:.3f} {(time.time() - t0) / 60:.1f} мин")

Наш запуск (THREADS=1 python train_basic.py):

символов: 890 723, словарь: 137
параметров: 827 264
loss до обучения: 4.922, ln V = 4.920
шаг 500 train 2.518 val 2.582 5.1 мин
шаг 1000 train 1.889 val 1.989 9.6 мин
шаг 1500 train 1.630 val 1.779 15.4 мин
шаг 2000 train 1.528 val 1.713 21.4 мин

За 2000 шагов val loss упал с 4.92 до 1.71: perplexity e1.71≈5.5e^{1.71} \approx 5.5 вместо 137 у свежей модели. Модель видела 8.2 млн символов — это около 10 проходов по обучающей части корпуса. Хорошо ли это — разберём в следующем уроке: там мы сравним результат с n-граммами и архиватором и выясним, откуда разрыв между train (1.53) и val (1.71).

Время. У нас обучение заняло 21 минуту на одном потоке процессора на сильно загруженном 4-ядерном сервере. Любопытная деталь: с четырьмя потоками на том же сервере обучение шло в несколько раз медленнее — потоки PyTorch мешали друг другу и чужим процессам. На свободном многоядерном процессоре несколько потоков ускорят счёт, но для такой маленькой модели больше 8–16 потоков обычно уже не помогают. Поэтому по умолчанию скрипт берёт min(8, os.cpu_count()) потоков, а переопределить число можно переменной окружения: THREADS=16 python train_basic.py. Замерьте минуты на 500 шагов при 1, 4, 8 и 16 потоках и выберите лучшее: время на вашем процессоре может заметно отличаться от наших 21 минуты в любую сторону.

model.eval()
prompt = torch.tensor([encode("Модель ")], dtype=torch.long) # форма (1, 7): батч из одной затравки
# generate возвращает (1, 7 + 300); [0] — первая (единственная) строка, tolist — в список чисел
print(decode(model.generate(prompt, 300, temperature=0.8, top_k=20)[0].tolist()))
# сохраняем веса, конфиг и словарь — всё, что нужно, чтобы потом восстановить модель
torch.save({"model": model.state_dict(), "cfg": cfg, "chars": chars}, "gpt_ru.pt")

Наша модель после 2000 шагов продолжила затравку «Модель » так:

Модель к — так же данные верньте,
а ,
Политику — «отражитие похожие», только коша неопределительный функции «репетить берёт помогают собирает — от дообучения с одними,
от данных простроена крактные почти тот же увсе тратистики от пост-трансфов (подробно выторы). Для при обучении статические сравнималис

Смысла нет, но посмотрите, что модель выучила за 20 минут, зная только символы:

  • слова: «данные», «похожие», «функции», «помогают», «собирает», «дообучения», «почти тот же», «при обучении»;
  • морфологию: окончания «-ает», «-ения», «-ные» стоят на своих местах, даже у несуществующих слов («неопределительный», «сравнималис»);
  • пунктуацию и оформление: тире с пробелами, «ёлочки», скобки, точки и переносы строк;
  • тематику корпуса: обучение, данные, дообучение. Модель на учебнике по LLM пишет про LLM.

Чего нет: согласования на уровне фразы и смысла. Контекст — 128 символов, параметров — 0.8M, а у GPT-2 small их в 150 раз больше, и токены у неё — целые куски слов. Двойные пробелы («к —») — след нашего корпуса: при его сборке prepare_corpus.py вырезал из предложений код, формулы и теги, на их месте остались лишние пробелы, и модель честно выучила и это.

Чекпоинт gpt_ru.pt сохраните: в уроке 4.6.4 на этой модели можно проверить генерацию с KV-кэшем. А в уроке 4.6.3 мы обучим ту же архитектуру заново, уже полноценным циклом, и сравним результат.

Проверь себя6 вопросов
1 Батч x формы (32,128)(32, 128), словарь 137. Какой формы логиты на выходе модели?
2 Сколько параметров в одном блоке GPT-2 ширины d=768d = 768 (с учётом смещений и LayerNorm)?
3 Какие утверждения про weight tying верны?Можно выбрать несколько ответов
4 Что произойдёт, если в CausalSelfAttention убрать is_causal=True и обучать модель?
5 Только что созданная модель со словарём 137 символов. Какой loss ожидать до обучения? Округлите до двух знаков.
6 Зачем generate обрезает контекст: idx[:, -block_size:]?
  • GPT на PyTorch — те же части, что в микро-GPT, но на тензорах: батч окон, все позиции сразу, причинная маска внутри scaled_dot_product_attention.
  • model.py в стиле nanoGPT повторяет архитектуру GPT-2 до параметра: с её размерами получается ровно 124 439 808.
  • Weight tying — общая матрица для эмбеддингов и LM-головы. Важен для маленьких моделей с большим словарём: у GPT-2 small эта матрица — 31% параметров. Qwen3 связывает веса у моделей до 4B и разделяет у 8B и больше.
  • Перед обучением сверьте число параметров с формулой L(12d2+13d)+Vd+Td+2dL(12d^2 + 13d) + Vd + Td + 2d и стартовый loss с ln⁡V\ln V.
  • За 2000 шагов (около 20 минут на одном потоке CPU) val loss упал с 4.92 до 1.71, и модель пишет текст, похожий на русский по форме — слова, окончания, пунктуация, — но без смысла.

Дальше: разберём цикл обучения как у больших LLM — разогрев и косинус, клиппинг и накопление градиентов, кривые train/val — и поймём, что значит loss 1.5 — Цикл обучения LLM.