Создание GPT с нуля на PyTorch: полный гайд от токенизации до обучения | AiManual
AiManual Logo Ai / Manual.
17 Янв 2026 Гайд

С нуля на PyTorch: собираем свою GPT по учебнику Рашки. Почему это сложнее, чем кажется

Пошаговая реализация GPT на PyTorch по книге Sebastian Raschka: токенизатор, трансформер-блоки, MultiHeadAttention, маскирование и авторегрессионное обучение.

Зачем вообще собирать 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
💡
Ключевой момент: токенизатор должен быть детерминированным. Если на одних и тех же данных он выдает разные токены — проверяйте seed и состояние генератора случайных чисел. Эта ошибка убивает воспроизводимость экспериментов.

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 учится смотреть на текст под разным углом.

Основные компоненты:

  1. Линейные проекции для Q, K, V
  2. Разделение на несколько голов
  3. Вычисление scaled dot-product attention
  4. Применение causal mask
  5. Объединение голов обратно
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}")

Ошибки, которые совершают все

Даже с идеальным кодом обучение может не работать. Вот что чаще всего ломается:

⚠️
1. Неправильная инициализация весов. Если не инициализировать веса (особенно эмбеддинги) — модель может застрять в локальном минимуме. Xavier инициализация работает лучше для трансформеров.
⚠️
2. Забыли causal mask во время обучения. Модель будет "подсматривать" будущие токены и не научится генерировать последовательности. Проверяйте mask перед каждым forward pass.
⚠️
3. Слишком большая температура при генерации. При temperature > 1.0 генерация становится слишком случайной, при temperature → 0 — детерминированной и скучной. Оптимально 0.7-0.9.
⚠️
4. Неправильный loss. CrossEntropyLoss ожидает логиты размерности (batch_size * seq_len, vocab_size), а не (batch_size, seq_len, vocab_size). Не забывайте reshape.

Что дальше? От учебной модели к 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 — это не просто учебник. Это карта сокровищ, где каждая глава раскрывает очередной слой магии трансформеров. Теперь у вас есть ключ к этой магии. Используйте его с умом.