Зачем вообще собирать GPT с нуля?
Потому что все берут готовые модели с Hugging Face, а потом не понимают, почему они падают в production. Потому что без понимания, как устроен каждый слой, вы не отладите градиенты. Потому что Sebastian Raschka в своей книге показал, что даже самая сложная архитектура сводится к последовательности понятных шагов.
Сегодня соберем маленькую, но полноценную GPT. Не игрушечную, а ту, которую можно доучить на своих данных. Поймем, где спрятаны подводные камни, и почему 80% ошибок происходят в казалось бы простых местах.
Это не "Hello World" трансформера. Это полный цикл от сырого текста до генерации. Если хотите просто поиграться — лучше возьмите готовую модель из нашего гайда по публикации на Hugging Face. Здесь будет больно, интересно и очень познавательно.
1 Токенизация: где большинство ломается на старте
Все начинают с BPE-токенизатора. И все сразу сталкиваются с проблемой OOV (out-of-vocabulary) токенов. Рашка предлагает SimpleTokenizerV2 — упрощенную версию, которая учится на лету. Главная фишка в том, что она не требует предобученного словаря.
Как НЕ надо делать токенизацию:
# Так делать НЕЛЬЗЯ
class BadTokenizer:
def __init__(self):
self.vocab = list("abcdefghijklmnopqrstuvwxyz ") # Очень наивно
def encode(self, text):
return [self.vocab.index(c) for c in text] # Упадет на любом символе вне алфавита
Проблема в том, что реальный текст — это не только буквы. Это эмодзи, пунктуация, опечатки. SimpleTokenizerV2 решает это через подсчет пар байтов и постепенное наращивание словаря.
import torch
from collections import defaultdict
class SimpleTokenizerV2:
"""Упрощенный BPE-токенизатор по мотивам книги Рашки"""
def __init__(self):
self.vocab = {}
self.merges = {}
def train(self, texts, vocab_size=1000):
"""Обучаемся на текстах, строим словарь"""
# Преобразуем текст в список байтов
tokens = [list(text.encode('utf-8')) for text in texts]
# Начинаем с базового словаря (0-255 байты)
self.vocab = {i: bytes([i]) for i in range(256)}
# Сливаем наиболее частые пары
for i in range(256, vocab_size):
# Находим самую частую пару токенов
pairs = defaultdict(int)
for token_list in tokens:
for j in range(len(token_list)-1):
pairs[(token_list[j], token_list[j+1])] += 1
if not pairs:
break
# Добавляем новую пару в словарь
most_frequent = max(pairs, key=pairs.get)
self.vocab[i] = self.vocab[most_frequent[0]] + self.vocab[most_frequent[1]]
self.merges[most_frequent] = i
# Обновляем все токены
new_tokens = []
for token_list in tokens:
i = 0
new_list = []
while i < len(token_list):
if i < len(token_list)-1 and (token_list[i], token_list[i+1]) == most_frequent:
new_list.append(self.merges[most_frequent])
i += 2
else:
new_list.append(token_list[i])
i += 1
new_tokens.append(new_list)
tokens = new_tokens
2 GPTDatasetV1: готовим данные для авторегрессии
Здесь многие совершают фатальную ошибку — не маскируют будущие токены. GPT — авторегрессионная модель. Она не должна видеть "будущее" во время обучения. Если забыть про causal mask — модель научится читерить.
Правильный датасет должен:
- Разбивать текст на контексты фиксированной длины
- Создавать маску, которая скрывает будущие токены
- Генерировать правильные таргеты (сдвинутые на один токен)
from torch.utils.data import Dataset
import torch
class GPTDatasetV1(Dataset):
"""Датасет для авторегрессионного обучения GPT"""
def __init__(self, token_ids, context_length):
self.token_ids = token_ids
self.context_length = context_length
def __len__(self):
return len(self.token_ids) - self.context_length
def __getitem__(self, idx):
# Берем контекст длины context_length
x = self.token_ids[idx:idx + self.context_length]
# Таргеты сдвинуты на 1 токен вперед
y = self.token_ids[idx + 1:idx + self.context_length + 1]
return torch.tensor(x), torch.tensor(y)
@staticmethod
def create_causal_mask(context_length):
"""Создаем маску для предотвращения утечки будущего"""
# Матрица размерности (context_length, context_length)
mask = torch.tril(torch.ones(context_length, context_length))
# Заменяем 0 на -inf, чтобы attention их игнорировал
mask = mask.masked_fill(mask == 0, float('-inf'))
# Нормализуем
mask = mask.masked_fill(mask == 1, 0)
return mask
Почему именно tril (нижняя треугольная матрица)? Потому что токен в позиции i может "видеть" только токены с позициями ≤ i. Токен 5 не должен знать про токен 6. Это фундаментальное ограничение авторегрессивных моделей.
3 MultiHeadAttention: сердце трансформера, которое все неправильно понимают
Большинство реализаций MultiHeadAttention в интернете работают, но не объясняют, зачем нужны все эти линейные слои. Рашка разбирает это по косточкам: каждый head учится смотреть на текст под разным углом.
Основные компоненты:
- Линейные проекции для Q, K, V
- Разделение на несколько голов
- Вычисление scaled dot-product attention
- Применение causal mask
- Объединение голов обратно
import torch.nn as nn
import torch.nn.functional as F
import math
class MultiHeadAttention(nn.Module):
def __init__(self, d_model, num_heads, dropout=0.1):
super().__init__()
assert d_model % num_heads == 0, "d_model must be divisible by num_heads"
self.d_model = d_model
self.num_heads = num_heads
self.d_k = d_model // num_heads
# Линейные слои для Q, K, V
self.W_q = nn.Linear(d_model, d_model)
self.W_k = nn.Linear(d_model, d_model)
self.W_v = nn.Linear(d_model, d_model)
self.W_o = nn.Linear(d_model, d_model)
self.dropout = nn.Dropout(dropout)
def forward(self, x, mask=None):
batch_size, seq_len, d_model = x.shape
# Проекции Q, K, V
Q = self.W_q(x) # (batch_size, seq_len, d_model)
K = self.W_k(x)
V = self.W_v(x)
# Разделяем на головы
Q = Q.view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2)
K = K.view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2)
V = V.view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2)
# Scaled dot-product attention
scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k)
# Применяем маску (если есть)
if mask is not None:
scores = scores.masked_fill(mask == 0, float('-inf'))
attention_weights = F.softmax(scores, dim=-1)
attention_weights = self.dropout(attention_weights)
# Взвешенная сумма значений
output = torch.matmul(attention_weights, V)
# Объединяем головы обратно
output = output.transpose(1, 2).contiguous().view(batch_size, seq_len, d_model)
# Финальная проекция
output = self.W_o(output)
return output
Важное замечание: деление на sqrt(d_k) — не прихоть, а необходимость. Без этого скалярные произведения становятся слишком большими, softmax вырождается в one-hot вектор, и градиенты исчезают. Это одна из тех деталей, которая сломает обучение, если ее пропустить.
4 Трансформер-блок: где прячется SpatialDropout и другие хитрости
Одиночный слой внимания — это еще не GPT. Нужны: нормализация, feed-forward сеть, остаточные связи и правильный dropout. Рашка использует SpatialDropout вместо обычного — это важно для последовательностей.
Почему SpatialDropout? Обычный Dropout выключает отдельные нейроны. SpatialDropout выключает целые каналы (векторные представления токенов). Для текста это работает лучше — модель учится быть устойчивой к потере целых концептов, а не отдельных признаков.
class TransformerBlock(nn.Module):
"""Один блок трансформера для GPT"""
def __init__(self, d_model, num_heads, d_ff, dropout=0.1):
super().__init__()
# Multi-head attention с остаточными связями
self.attention = MultiHeadAttention(d_model, num_heads, dropout)
self.norm1 = nn.LayerNorm(d_model)
# Feed-forward сеть
self.feed_forward = nn.Sequential(
nn.Linear(d_model, d_ff),
nn.GELU(), # GELU работает лучше ReLU для трансформеров
nn.Linear(d_ff, d_model),
nn.Dropout(dropout)
)
self.norm2 = nn.LayerNorm(d_model)
# SpatialDropout для всего блока
self.dropout = nn.Dropout2d(dropout) if dropout > 0 else nn.Identity()
def forward(self, x, mask=None):
# Attention с остаточной связью
attn_output = self.attention(x, mask)
x = self.norm1(x + attn_output)
# Feed-forward с остаточной связью
ff_output = self.feed_forward(x)
x = self.norm2(x + ff_output)
# Применяем SpatialDropout
if isinstance(self.dropout, nn.Dropout2d):
x = x.transpose(1, 2) # (batch, d_model, seq_len)
x = self.dropout(x)
x = x.transpose(1, 2) # обратно
return x
5 Собираем GPT: от эмбеддингов до генерации текста
Теперь все компоненты готовы. Осталось собрать их в единую архитектуру. Ключевые моменты:
- Токенные эмбеддинги + позиционные энкодинги
- Стек трансформер-блоков
- Финальный слой для предсказания следующего токена
- Температура для контроля случайности генерации
class SimpleGPT(nn.Module):
"""Минимальная реализация GPT по мотивам книги Рашки"""
def __init__(self, vocab_size, context_length, d_model=256,
num_heads=8, num_layers=6, d_ff=1024, dropout=0.1):
super().__init__()
self.context_length = context_length
# Токенные и позиционные эмбеддинги
self.token_embedding = nn.Embedding(vocab_size, d_model)
self.position_embedding = nn.Embedding(context_length, d_model)
# Стек трансформер-блоков
self.blocks = nn.ModuleList([
TransformerBlock(d_model, num_heads, d_ff, dropout)
for _ in range(num_layers)
])
# Нормализация и финальный слой
self.norm = nn.LayerNorm(d_model)
self.lm_head = nn.Linear(d_model, vocab_size)
# Инициализация весов
self._init_weights()
def _init_weights(self):
"""Инициализация Xavier для лучшей сходимости"""
for p in self.parameters():
if p.dim() > 1:
nn.init.xavier_uniform_(p)
def forward(self, token_ids):
batch_size, seq_len = token_ids.shape
# Создаем позиционные индексы
positions = torch.arange(seq_len, device=token_ids.device).unsqueeze(0)
positions = positions.expand(batch_size, seq_len)
# Эмбеддинги токенов и позиций
token_emb = self.token_embedding(token_ids)
pos_emb = self.position_embedding(positions)
x = token_emb + pos_emb
# Causal mask
mask = GPTDatasetV1.create_causal_mask(seq_len)
mask = mask.to(token_ids.device)
# Проходим через все блоки
for block in self.blocks:
x = block(x, mask)
# Нормализация и предсказание
x = self.norm(x)
logits = self.lm_head(x)
return logits
@torch.no_grad()
def generate(self, prompt, tokenizer, max_length=100, temperature=0.8):
"""Генерация текста с температурой"""
self.eval()
# Токенизируем промпт
input_ids = tokenizer.encode(prompt)
input_ids = torch.tensor(input_ids).unsqueeze(0).to(next(self.parameters()).device)
generated = input_ids.tolist()[0]
for _ in range(max_length - len(generated)):
# Берем последние context_length токенов
context = input_ids[:, -self.context_length:]
# Получаем логиты
logits = self(context)
# Берем логиты для последнего токена
next_token_logits = logits[:, -1, :] / temperature
# Применяем softmax и сэмплируем
probs = F.softmax(next_token_logits, dim=-1)
next_token = torch.multinomial(probs, num_samples=1)
# Добавляем к последовательности
input_ids = torch.cat([input_ids, next_token], dim=1)
generated.append(next_token.item())
return tokenizer.decode(generated)
Обучение: где градиенты исчезают и как их вернуть
Теперь самая болезненная часть — обучение. Если все сделано правильно, но модель не учится, проверьте эти моменты:
| Проблема | Решение | Почему важно |
|---|---|---|
| Градиенты исчезают | Остаточные связи + LayerNorm | Без них сигнал не проходит через глубокие сети |
| Переобучение | SpatialDropout + Weight Decay | Модель запоминает данные вместо общих паттернов |
| Медленная сходимость | AdamW с warmup | Позволяет аккуратно настроить learning rate |
| Взрыв градиентов | Gradient Clipping | Стабилизирует обучение глубоких сетей |
def train_gpt(model, dataset, epochs=10, batch_size=32, lr=1e-4):
"""Процедура обучения GPT"""
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model.to(device)
dataloader = DataLoader(dataset, batch_size=batch_size, shuffle=True)
optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
criterion = nn.CrossEntropyLoss()
# Learning rate warmup
scheduler = torch.optim.lr_scheduler.LinearLR(
optimizer, start_factor=0.01, total_iters=1000
)
for epoch in range(epochs):
model.train()
total_loss = 0
for batch_idx, (x, y) in enumerate(dataloader):
x, y = x.to(device), y.to(device)
optimizer.zero_grad()
# Forward pass
logits = model(x)
# Reshape для CrossEntropyLoss
loss = criterion(
logits.view(-1, logits.size(-1)),
y.view(-1)
)
# Backward pass с gradient clipping
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
scheduler.step()
total_loss += loss.item()
if batch_idx % 100 == 0:
print(f"Epoch {epoch+1}, Batch {batch_idx}, Loss: {loss.item():.4f}")
avg_loss = total_loss / len(dataloader)
print(f"Epoch {epoch+1} completed. Average loss: {avg_loss:.4f}")
Ошибки, которые совершают все
Даже с идеальным кодом обучение может не работать. Вот что чаще всего ломается:
Что дальше? От учебной модели к production
Вы собрали работающую GPT. Она генерирует текст (пусть и не Shakespeare). Что делать теперь?
1. Масштабирование. Увеличивайте d_model, num_layers, context_length. Но помните: удвоение параметров требует в 4 раза больше памяти. Если хотите поэкспериментировать с большими моделями — посмотрите наш разбор проблем с обучением Llama 3.2.
2. Дообучение на доменных данных. Возьмите код из гайда по дообучению моделей и адаптируйте его под свою архитектуру.
3. Оптимизация инференса. Квантование, pruning, distillation. Для этого пригодится AutoRound — наш гайд по квантованию.
4. Переход на более эффективные архитектуры. Если трансформеры кажутся слишком тяжелыми — посмотрите на Mamba с её State Space Models.
Самое главное, что вы теперь понимаете не только как вызывать model.generate(), но и что происходит внутри. Это знание стоит месяцев чтения документации и статей. Теперь, когда в следующий раз перейдете от прототипа к production, вы не будете гадать, почему модель падает на длинных последовательностях или генерирует бессмысленный текст.
Книга Sebastian Raschka — это не просто учебник. Это карта сокровищ, где каждая глава раскрывает очередной слой магии трансформеров. Теперь у вас есть ключ к этой магии. Используйте его с умом.