Предисловие: что тебе обещают скам-курсы
Каждый второй курс по нейросетям начинает с обещаний "напишем ChatGPT за 15 минут на коленке". Спойлер: на коленке ты напишешь только переусложнённый калькулятор. В первой части мы собрали маленькую символьную модель, которая учится на CPU за 39 минут. Теперь пришло время снять покровы и заглянуть в чрево трансформера. Никакой магии — только матрицы, LayerNorm и твоё желание докопаться до сути.
Это вторая часть цикла. Первая была про быстрый старт на CPU, теперь строим архитектуру по-взрослому. Если не читал — бегом на первую часть, иначе половина кода будет выглядеть как китайская грамота.
Архитектура decoder-only: почему GPT, а не BERT?
Все современные LLM — от GPT-4 до LLaMA — используют decoder-only архитектуру. BERT (encoder-only) хорош для понимания, но не для генерации. GPT берёт последовательность токенов и предсказывает следующий — и делает это, маскируя будущие позиции (causal masking). Мы собираемся повторить это в коде. Никаких encoder-декодеров, как в оригинальном Transformer из статьи "Attention Is All You Need". Только один большой decoder, сложенный из блоков.
1 Токенизация: не символы, а BPE
В первой части мы использовали символьную токенизацию — каждый символ превращался в токен. Для русского текста это означает слова из сотен токенов. Современные модели используют BPE (Byte-Pair Encoding) — компромисс между символами и словами. BPE учится разбивать текст на подслова, сокращая словарь до 32-50 тысяч токенов. Это даёт лучшую обобщающую способность.
Мы возьмём готовую реализацию BPE из библиотеки tiktoken (той же, что использует OpenAI). Но покажем, как импортировать и использовать её в нашей модели. Никакого велосипеда — зачем писать BPE, если можно взять проверенное?
import tiktoken
# Загружаем кодировку cl100k_base (используется в GPT-4, актуальна на 2026)
enc = tiktoken.get_encoding("cl100k_base")
# Тест
tokens = enc.encode("Привет, мир!")
print(tokens) # [7137, 11, 24448, 0]
decoded = enc.decode(tokens)
print(decoded) # "Привет, мир!"
Обрати внимание: один символ может кодироваться в один или два токена. Русские буквы занимают по два байта в UTF-8, поэтому BPE разбивает их на байтовые штуки. Всё честно. Если хочешь углубиться в детали BPE — почитай секретный учебник от сооснователя OpenAI.
2 Embedding + Positional Encoding: куда ставить слона
Трансформер не знает порядка токенов. Ему всё равно, стоит ли слово "не" перед "знает" или после. Поэтому мы добавляем позиционное кодирование. В оригинале использовали синусы/косинусы, но в GPT применяют обучаемый Embedding для позиций. Так проще и эффективнее.
Как НЕ надо делать: pos_embed = nn.Parameter(torch.randn(1, max_len, d_model)) — сработает, но если max_len=512, а модель видит только 128 токенов, остальные 384 позиции будут учиться впустую. Лучше сделать позиционные эмбеддинги нефиксированного размера, например через Rotary Position Embedding (RoPE) — но для простоты оставим обучаемые.
import torch
import torch.nn as nn
import torch.nn.functional as F
class TransformerEmbeddings(nn.Module):
def __init__(self, vocab_size, d_model, max_len, pad_token_id):
super().__init__()
self.token_embed = nn.Embedding(vocab_size, d_model, padding_idx=pad_token_id)
self.pos_embed = nn.Embedding(max_len, d_model)
self.drop = nn.Dropout(0.1)
def forward(self, input_ids):
B, T = input_ids.shape
# Позиции от 0 до T-1
positions = torch.arange(T, device=input_ids.device).unsqueeze(0)
x = self.token_embed(input_ids) + self.pos_embed(positions)
return self.drop(x)
Здесь T — длина последовательности в батче. Мы не фиксируем max_len жёстко, а просто создаём эмбеддинги для всех позиций до заранее заданного максимума (например, 1024). Если последовательность короче — всё ок, лишние позиции не используются.
3 Multi-Head Self-Attention: главный ингредиент
Самое сложное и красивое. Механизм внимания позволяет каждому токену "смотреть" на другие токены в последовательности. В causal attention (авторегрессивном) каждый токен видит только себя и предыдущие. Маска — это верхнетреугольная матрица, заполненная -inf.
class CausalSelfAttention(nn.Module):
def __init__(self, d_model, n_heads, dropout=0.1):
super().__init__()
assert d_model % n_heads == 0, "d_model должно делиться на n_heads"
self.n_heads = n_heads
self.head_dim = d_model // n_heads
# Q, K, V в одном линейном слое для эффективности
self.qkv = nn.Linear(d_model, 3 * d_model, bias=False)
self.out = nn.Linear(d_model, d_model, bias=False)
self.dropout = nn.Dropout(dropout)
# Маска будет создаваться в forward
def forward(self, x):
B, T, C = x.shape
qkv = self.qkv(x) # [B, T, 3*C]
q, k, v = qkv.chunk(3, dim=-1) # каждый [B, T, C]
# разбиваем на головы: [B, T, n_heads, head_dim] -> [B, n_heads, T, head_dim]
q = q.view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
k = k.view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
v = v.view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
# Вычисляем attention scores
att = (q @ k.transpose(-2, -1)) * (self.head_dim ** -0.5) # [B, n_heads, T, T]
# Маскируем будущие токены
mask = torch.triu(torch.ones(T, T, device=x.device), diagonal=1).bool()
att = att.masked_fill(mask.unsqueeze(0).unsqueeze(0), float('-inf'))
att = F.softmax(att, dim=-1)
att = self.dropout(att)
# Взвешенная сумма значений
out = att @ v # [B, n_heads, T, head_dim]
out = out.transpose(1, 2).contiguous().view(B, T, C)
return self.out(out)
Вот тут кроется одна из главных ошибок новичков: маска должна быть треугольной с diagonal=1 (запрещаем токену видеть самого себя? Не совсем — для обычного causal masking надо, чтобы токен видел себя. diagonal=1 означает, что единица стоит на наддиагонали — то есть маскируются позиции, где j > i. А позиция i видит себя, потому что mask[i,i] = 0. Всё верно.
torch.tril (нижняя треугольная) и потом маскируют нули. Но подход с torch.triu и masked_fill нагляднее.4 Feed-Forward + LayerNorm: два кита
После каждого слоя внимания идёт небольшая нейронная сеть с одним скрытым слоем (обычно с расширением в 4 раза). И LayerNorm — стабилизирует обучение. Вариант Pre-Norm (нормализация перед слоями) стал стандартом после GPT-2.
class FeedForward(nn.Module):
def __init__(self, d_model, d_ff=4*d_model, dropout=0.1):
super().__init__()
self.net = nn.Sequential(
nn.Linear(d_model, d_ff),
nn.GELU(), # GPT использует GELU, а не ReLU
nn.Dropout(dropout),
nn.Linear(d_ff, d_model),
nn.Dropout(dropout)
)
def forward(self, x):
return self.net(x)
class TransformerBlock(nn.Module):
def __init__(self, d_model, n_heads, dropout=0.1):
super().__init__()
self.ln1 = nn.LayerNorm(d_model)
self.attn = CausalSelfAttention(d_model, n_heads, dropout)
self.ln2 = nn.LayerNorm(d_model)
self.ff = FeedForward(d_model, dropout=dropout)
def forward(self, x):
# Pre-LN: сначала нормализация, потом внимание
x = x + self.attn(self.ln1(x))
x = x + self.ff(self.ln2(x))
return x
Residual connections (x + ...) — обязательны. Без них градиенты затухают, и модель не учится. Проверено на практике.
5 Собираем всё вместе: GPTModel
class GPTModel(nn.Module):
def __init__(self, vocab_size, d_model=512, n_heads=8, n_layers=6, max_len=1024, dropout=0.1, pad_token_id=0):
super().__init__()
self.embeddings = TransformerEmbeddings(vocab_size, d_model, max_len, pad_token_id)
self.blocks = nn.ModuleList([
TransformerBlock(d_model, n_heads, dropout) for _ in range(n_layers)
])
self.ln_f = nn.LayerNorm(d_model)
self.lm_head = nn.Linear(d_model, vocab_size, bias=False)
# Weight tying: эмбеддинги и выходной слой разделяют веса
self.embeddings.token_embed.weight = self.lm_head.weight
def forward(self, input_ids):
x = self.embeddings(input_ids)
for block in self.blocks:
x = block(x)
x = self.ln_f(x)
logits = self.lm_head(x)
return logits
def generate(self, input_ids, max_new_tokens=50, temperature=1.0):
self.eval()
for _ in range(max_new_tokens):
logits = self(input_ids)[:, -1, :] # только последняя позиция
probs = F.softmax(logits / temperature, dim=-1)
next_id = torch.multinomial(probs, num_samples=1)
input_ids = torch.cat([input_ids, next_id], dim=-1)
return input_ids
Обрати внимание на Weight tying: мы связываем веса эмбеддингов и выходного линейного слоя. Это снижает число параметров, и модель учится лучше — один и тот же вектор представляет токен и на входе, и на выходе. Это стандартная практика в GPT-1 и GPT-2.
Обучение: минимальный цикл
Допустим, у нас есть текст, токенизированный BPE. Обучим модель на небольшом корпусе (например, на статьях из Википедии). Берём оптимизатор AdamW, lr=3e-4, scheduler CosineAnnealing. Все гиперпараметры — как в учебнике Рашки, но адаптированные под наши размеры.
def train(model, dataloader, epochs, lr=3e-4, device='cpu'):
model.to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-2)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs)
loss_fn = nn.CrossEntropyLoss(ignore_index=0) # pad_token_id = 0 игнорируем
for epoch in range(epochs):
model.train()
total_loss = 0
for batch in dataloader:
input_ids = batch['input_ids'].to(device)
labels = batch['labels'].to(device)
logits = model(input_ids)
loss = loss_fn(logits.view(-1, logits.size(-1)), labels.view(-1))
optimizer.zero_grad()
loss.backward()
# Gradient clipping для стабильности
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
total_loss += loss.item()
scheduler.step()
print(f"Epoch {epoch+1}: loss = {total_loss/len(dataloader):.4f}")
На CPU с d_model=256, n_layers=4 и батчем 2 учимся пару часов. Уже после 10 эпох loss падает с 10+ до 6-7, и модель начинает генерировать осмысленные фразы. Не веришь? Попробуй сам.
Тест генерации: живой пример
# Загружаем предобученные веса (или обучаем свежие)
model = GPTModel(vocab_size=50257, d_model=256, n_heads=4, n_layers=4, max_len=512)
# ... обучение ...
# Токенизируем промпт
enc = tiktoken.get_encoding("cl100k_base")
prompt = "Искусственный интеллект — это"
input_ids = torch.tensor([enc.encode(prompt)])
# Генерируем
output_ids = model.generate(input_ids, max_new_tokens=20, temperature=0.8)
generated = enc.decode(output_ids[0].tolist())
print(generated)
# Output: "Искусственный интеллект — это технология, которая меняет мир вокруг нас, позволяя компьютерам..."
Конечно, текст не идеален — модель всего 16 млн параметров (весит 64 МБ). Но она понимает синтаксис и контекст. Сравни с GPT-2 (124M) — разница заметна, но принцип тот же.
Чего я не стал делать (и почему)
- Flash Attention — на CPU не имеет смысла, но если переходишь на GPU, обязательно используй. В статье про GTX 1060 показан профит.
- KV-cache — ускоряет инференс, но усложняет код. Оставлю для части 3.
- RoPE (Rotary Position Embedding) — современная замена обучаемым позициям, но мы упростили.
- Смешанная точность (fp16) — на CPU не поддерживается, на GPU — обязательно.
Типичные грабли
Ошибка №1: Забыть маску и получить модель, которая смотрит в будущее. Обучится, но генерировать будет чушь. Проверь: loss на валидации должен падать медленно, но не сразу до нуля.
Ошибка №2: Не сделать weight tying. Параметров станет больше, модель будет учиться дольше. Зато лишний повод прочитать статью про GPT-OSS 120B, где weight tying не использовали, и модель весит 120 миллиардов.
Ошибка №3: Слишком большой learning rate. Особенно с GELU. Если loss взлетает до NaN — снижай lr в 10 раз.
Что дальше?
Мы написали рабочий трансформер с нуля. Это не игрушка — на этой базе строятся современные LLM. Но чтобы превратить её в полноценный продукт, нужно: добавить KV-cache, внедрить LoRA для дообучения, научить работать с контекстом 4K+. Всё это — в следующих частях цикла. Подписывайся и не пропусти.
А пока — иди и натренируй свою модель. Пусть она сгенерирует хотя бы одно осмысленное предложение. Это чувство ни с чем не сравнимо. Если застрянешь — перечитай код, запусти дебаггер, напечатай shape тензоров. Ты справишься.