GPT на PyTorch
Пошагово собираем GPT в стиле nanoGPT — эмбеддинги, блоки, LM-голову со связанными весами, — обучаем её на миллионе символов русского текста прямо на процессоре и генерируем текст.
Зачем это нужно
Заголовок раздела «Зачем это нужно»Микро-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.
Шаг 0. Корпус
Заголовок раздела «Шаг 0. Корпус»Нужен русский текст на 1–3 МБ. Подойдёт любой, но интереснее всего классика в общественном достоянии.
Вариант А: Пушкин или сказки. Скопируйте с Викитеки «Сказку о царе Салтане»,
«Руслана и Людмилу», «Евгения Онегина», народные сказки — всё, что найдёте, в один файл input.txt в кодировке
UTF-8. Чем больше текста, тем лучше: миллион символов — хороший минимум.
Вариант Б: тексты этого курса. Если Пушкина под рукой нет, обучите модель на русской прозе уроков ChinaAI.
Они уже лежат в вашей копии репозитория. Скрипт собирает из MDX-файлов обычные абзацы, выбрасывая код, формулы
и разметку. Он держится на регулярных выражениях (re.sub(шаблон, замена, текст); азбуку шаблонов мы разбирали
в уроке 4.2.2). Вникать в каждый шаблон не обязательно — это вспомогательный
инструмент, достаточно его запустить:
"""prepare_corpus.py — русские тексты уроков курса → input.txt. Запуск из корня репозитория."""import pathlibimport 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, начнёт писать фразы про модели и токены. Это наглядно показывает: языковая модель — сжатая статистика своего корпуса.
Шаг 1. Посимвольный токенизатор
Заголовок раздела «Шаг 1. Посимвольный токенизатор»Как и в микро-GPT, токен — один символ. Словарь строим по корпусу, предварительно выбросив редкие символы (иероглифы, эмодзи, ударения), которые только раздувают словарь:
"""train_basic.py — данные, батчи, простой цикл обучения и генерация. python train_basic.py"""import mathimport osimport 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%, — в следующем уроке.
Шаг 2. Батч: окна со сдвигом на один символ
Заголовок раздела «Шаг 2. Батч: окна со сдвигом на один символ»Обучающий пример — окно из 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 — . Это предсказаний за один проход модели.
Шаг 3. Модель по частям
Заголовок раздела «Шаг 3. Модель по частям»Создайте файл model.py. Начнём с конфигурации:
"""model.py — GPT в стиле nanoGPT (GPT-2): эмбеддинги, блоки, LM-голова, weight tying."""import mathfrom dataclasses import dataclass
import torchimport torch.nn as nnfrom torch.nn import functional as F
@dataclassclass GPTConfig: vocab_size: int = 128 block_size: int = 128 # максимальная длина контекста n_layer: int = 4 n_head: int = 4 n_embd: int = 128 dropout: float = 0.03a. Причинное внимание
Заголовок раздела «3a. Причинное внимание»Это многоголовое внимание из урока 4.4.3 с одной оптимизацией из GPT-2:
три матрицы склеены в одну c_attn размера , а результат режется на три части.
Одно большое умножение быстрее трёх маленьких.
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 считает
одним вызовом и на GPU сам выбирает FlashAttention. В микро-GPT маска была не нужна: токены приходили по одному.
Здесь все 128 позиций считаются одновременно, и без причинной маскипричинная (каузальная) маскаEN causal maskЗапрещает токену смотреть на будущие позиции — оценки выше диагонали заменяются на −∞ до softmax. Нужна, чтобы GPT-подобная модель училась предсказывать следующий токен на всех позициях сразу. позиция 5 видела
бы ответ на позиции 6. Модель «научилась» бы списывать: train loss рухнул бы почти до нуля, а генерация была бы
мусором.
3b. MLP и блок
Заголовок раздела «3b. MLP и блок»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).
3c. Вся модель
Заголовок раздела «3c. Вся модель»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 с формами для батча , , , :
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 позициям"]
Детали, которых не было в микро-GPT:
ln_f— финальная нормализация перед головой. Остаточный поток к концу сети накапливает поправки всех блоков, и его масштаб может быть любым. LM-голове удобнее работать с нормированным вектором.F.cross_entropyсview(-1, V)— функция ждёт матрицу «примеры × классы», поэтому превращаем в , а цели — в вектор из 4096 чисел. Loss — среднее по всем позициям.- Инициализация. Все веса — нормальное распределение со стандартным отклонением 0.02, как в GPT-2. А у
c_proj(последней матрицы внимания и MLP) ещё в раз меньше. Причина: каждый из подслоёв прибавляет свой выход к остаточному потоку, и дисперсия суммы растёт с числом слагаемых. Поправка держит масштаб потока примерно постоянным при любой глубине. Этот приём описан в статье GPT-2. generateберёт логиты только последней позиции (она предсказывает следующий токен) и обрезает контекст доblock_size: позиционных эмбеддингов ровно столько, дальше модель позиций не знает.
Weight tying: одна матрица на вход и выход
Заголовок раздела «Weight tying: одна матрица на вход и выход»Строка self.wte.weight = self.lm_head.weight делает эмбеддинги токенов и LM-голову одним и тем же тензором.
Это связывание весовсвязывание весов (общие эмбеддинги входа и выхода)EN weight tying (tied embeddings)Одна и та же матрица служит эмбеддингом токенов и LM head. Её градиент — сумма вкладов от обоих мест. В Hugging Face — флаг tie_word_embeddings..
Почему это разумно. Матрица эмбеддингов размера переводит токен в вектор: строка — «смысл» токена . LM-голова делает обратное: логит токена — это скалярное произведение выхода сети со строкой : . Если , то больший логит получает токен, чей эмбеддинг сильнее всего «совпадает по направлению» с выходом сети (скалярное произведение больше). Похожие токены и на входе, и на выходе получают похожие векторы, а это нам и нужно. Пресс и Вольф в 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 токенов, и матрица — почти треть модели. Отсюда правило: связывание важно для маленьких моделей с большим словарём. У больших моделей матрица эмбеддингов — единицы процентов и меньше (у DeepSeek-V3 — около 0.1%), вторая копия стоит недорого, и обычно берут две независимые матрицы: так у входа и выхода больше свободы. Ниже, в китайском углу, видно, как ровно по этой границе решают Qwen и MiniMind.
Кстати, 124 439 808 — ровно число параметров настоящей GPT-2 small. Архитектура совпадает до последнего смещения,
поэтому в нашу модель можно загрузить веса OpenAI (в nanoGPT для этого есть GPT.from_pretrained).
Шаг 4. Проверки до обучения
Заголовок раздела «Шаг 4. Проверки до обучения»Число параметров по формуле
Заголовок раздела «Число параметров по формуле»В одном блоке GPT-2 ширины :
| Слой | Веса | Смещения |
|---|---|---|
c_attn (Q, K, V) | ||
attn.c_proj | ||
mlp.c_fc | ||
mlp.c_proj | ||
ln_1, ln_2 | — | |
| Блок |
Плюс эмбеддинги токенов (общие с головой), позиций и ln_f . Для нашей модели (, ,
, ):
Ровно столько печатает скрипт. Если ваше число не сходится с формулой — где-то лишний или пропущенный слой. Если в вашем корпусе словарь не 137, а, скажем, 138 символов, параметров станет на 128 больше: у общей матрицы эмбеддингов и головы появится ещё одна строка.
Loss до обучения ≈ ln V
Заголовок раздела «Loss до обучения ≈ ln V»Только что созданная модель выдаёт почти равномерное распределение, её loss должен быть около
(урок 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, то есть ,
логиты получаются огромными, и loss на старте около 89: модель с первого шага «уверенно ошибается», и первые
сотни шагов уходят на то, чтобы просто сбить эту уверенность.
Шаг 5. Обучение
Заголовок раздела «Шаг 5. Обучение»Самый простой цикл: AdamWAdamWEN AdamW (Adam with decoupled weight decay)Adam, в котором затухание весов применяется отдельно от адаптивного шага. Оптимизатор по умолчанию для обучения LLM. с постоянным learning rate , 2000 шагов. Раз в 500 шагов меряем loss на 20 батчах из train и из val.
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)max_steps = 2000t0 = 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 264loss до обучения: 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 вместо 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 минуты в любую сторону.
Шаг 6. Генерация
Заголовок раздела «Шаг 6. Генерация»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 мы обучим ту же архитектуру заново, уже полноценным циклом,
и сравним результат.
Проверь себя
Заголовок раздела «Проверь себя»- 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 и больше.
- Перед обучением сверьте число параметров с формулой и стартовый loss с .
- За 2000 шагов (около 20 минут на одном потоке CPU) val loss упал с 4.92 до 1.71, и модель пишет текст, похожий на русский по форме — слова, окончания, пунктуация, — но без смысла.
Дальше: разберём цикл обучения как у больших LLM — разогрев и косинус, клиппинг и накопление градиентов, кривые train/val — и поймём, что значит loss 1.5 — Цикл обучения LLM.