Transformer с нуля на Python и Numpy: гайд с кодом и автодифом | AiManual
AiManual Logo Ai / Manual.
17 Фев 2026 Гайд

Как с нуля написать и обучить Трансформер на чистом Python и Numpy: полный гайд с кодом

Пошаговое создание Transformer с нуля на чистом Python и Numpy. Реализация автограда, внимания, обучения. Полный код без PyTorch/TensorFlow.

Почему вам нужна эта боль? Или зачем писать трансформер на чистом Numpy

Каждый второй сегодня использует готовые модели из библиотек вроде Transformers v5. Берешь, загружаешь, fine-tune'ишь — профит. Но что происходит внутри? Магия? Черный ящик?

Вот цифра: 95% разработчиков, использующих трансформеры, не могут объяснить, как работает механизм внимания на уровне матричных операций. Они просто импортируют MultiHeadAttention и молятся, чтобы все заработало.

Самый частый вопрос на собеседованиях по ML: "Расскажите, как работает self-attention". 80% кандидатов начинают путаться после второго уравнения.

Проблема в том, что современные фреймворки скрывают слишком много. Когда вы пишете model = Transformer(), вы получаете готовую абстракцию, но теряете понимание. А без понимания вы не сможете:

  • Отлаживать странное поведение модели
  • Модифицировать архитектуру под свои задачи
  • Оптимизировать память и вычисления
  • Писать свои CUDA-ядра, как в статье про агентов Codex и Claude

Решение? Написать все с нуля. На чистом Python и Numpy. Без PyTorch, без TensorFlow, без готовых слоев. Только вы, матрицы и производные.

Что такое автодиф на самом деле? И почему все делают это неправильно

Перед тем как писать трансформер, нужно понять, как работает автоматическое дифференцирование. Большинство думает, что автодиф — это какая-то магия. На самом деле это просто умное применение цепного правила.

Типичная ошибка начинающих: пытаться считать градиенты вручную для каждой операции. Это ад. Вместо этого мы построим динамический вычислительный граф.

1 Создаем тензор с историей

Каждый тензор в нашей системе должен помнить:

import numpy as np

class Tensor:
    def __init__(self, data, requires_grad=False, _op='', _children=()):
        self.data = np.array(data, dtype=np.float32)
        self.requires_grad = requires_grad
        self.grad = np.zeros_like(self.data) if requires_grad else None
        self._op = _op  # операция, которая создала этот тензор
        self._children = set(_children)  # дочерние тензоры
        self._backward = lambda: None  # функция обратного распространения
        
    def __repr__(self):
        return f"Tensor(data={self.data.shape}, grad={self.grad is not None})"

Ключевая идея: каждый раз, когда мы выполняем операцию (сложение, умножение, etc.), мы создаем новый тензор, который знает, как его создали и кто его родители.

💡
Это называется топологический автоград. Мы строим граф операций на лету, а потом обходим его в обратном порядке для backpropagation.

2 Реализуем базовые операции

Добавляем поддержку основных математических операций. Каждая операция должна:

  1. Вычислить результат
  2. Создать новый тензор с ссылкой на родителей
  3. Определить функцию для обратного распространения
def matmul(self, other):
    """Умножение матриц с автоградом"""
    out = Tensor(self.data @ other.data,
                 requires_grad=self.requires_grad or other.requires_grad,
                 _op='matmul',
                 _children=(self, other))
    
    def _backward():
        if self.requires_grad:
            # dL/dA = dL/dC * B^T
            self.grad += out.grad @ other.data.T
        if other.requires_grad:
            # dL/dB = A^T * dL/dC
            other.grad += self.data.T @ out.grad
    
    out._backward = _backward
    return out

Tensor.__matmul__ = matmul

Самая частая ошибка здесь — забыть аккумулировать градиенты (+= вместо =). Почему аккумуляция? Потому что один тензор может использоваться в нескольких операциях.

Механизм внимания: не то, чем кажется

Все говорят про attention, но мало кто понимает, что это просто три линейных слоя и софтмакс. Давайте разберемся.

Self-attention — это способ позволить каждому токену "видеть" все остальные токены в последовательности. Но не просто видеть, а взвешенно обращать внимание.

Компонент Что делает Размерность
Query (Q) Что ищем [batch, seq_len, d_k]
Key (K) По чему ищем [batch, seq_len, d_k]
Value (V) Что возвращаем [batch, seq_len, d_v]

Вот как выглядит скалярное произведение внимания (scaled dot-product attention):

def attention(q, k, v, mask=None):
    """Вычисляем attention scores"""
    d_k = q.data.shape[-1]
    
    # Q * K^T / sqrt(d_k)
    scores = (q @ k.transpose(-2, -1)) / np.sqrt(d_k)
    
    if mask is not None:
        scores = scores + mask * -1e9  # добавляем маску
    
    # softmax по последней оси
    attn_weights = softmax(scores)
    
    # взвешенная сумма values
    output = attn_weights @ v
    
    return output, attn_weights

Зачем делить на sqrt(d_k)? Без этого софтмакс становится слишком "острым" — один токен получает почти всю вероятность, остальные — почти ноль. Это называется проблемой vanishing gradients.

3 Multi-Head Attention: параллельные вселенные внимания

Одна голова хорошо, а восемь — лучше. Multi-head attention позволяет модели одновременно обращать внимание на разные типы информации.

class MultiHeadAttention:
    def __init__(self, d_model, 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 = Linear(d_model, d_model)
        self.w_k = Linear(d_model, d_model)
        self.w_v = Linear(d_model, d_model)
        self.w_o = Linear(d_model, d_model)
        
    def forward(self, q, k, v, mask=None):
        batch_size = q.data.shape[0]
        
        # Линейные преобразования
        q = self.w_q(q)  # [batch, seq_len, d_model]
        k = self.w_k(k)
        v = self.w_v(v)
        
        # Разделяем на головы
        q = q.reshape(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
        k = k.reshape(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
        v = v.reshape(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
        
        # Вычисляем attention для каждой головы
        scores = (q @ k.transpose(-2, -1)) / np.sqrt(self.d_k)
        
        if mask is not None:
            scores = scores + mask.unsqueeze(1)  # добавляем маску для всех голов
            
        attn_weights = softmax(scores)
        context = attn_weights @ v
        
        # Собираем головы обратно
        context = context.transpose(1, 2).reshape(batch_size, -1, self.d_model)
        
        # Финальное линейное преобразование
        output = self.w_o(context)
        
        return output, attn_weights

Почему это работает? Каждая голова учится обращать внимание на разные аспекты данных. Одна — на синтаксис, другая — на семантику, третья — на позиционную информацию.

Позиционное кодирование: как трансформер понимает порядок

Трансформеры не имеют встроенного понимания порядка слов. Они видят все токены одновременно. Решение? Позиционные эмбеддинги.

Оригинальная бумага использует синусоидальные функции:

def positional_encoding(max_len, d_model):
    """Синусоидальное позиционное кодирование"""
    pe = np.zeros((max_len, d_model))
    
    position = np.arange(max_len).reshape(-1, 1)
    div_term = np.exp(np.arange(0, d_model, 2) * -(np.log(10000.0) / d_model))
    
    pe[:, 0::2] = np.sin(position * div_term)  # четные индексы
    pe[:, 1::2] = np.cos(position * div_term)  # нечетные индексы
    
    return Tensor(pe, requires_grad=False)

Почему именно синусы и косинусы? Потому что они позволяют модели легко интерполировать позиции для последовательностей длиннее, чем те, на которых она обучалась.

В современных моделях часто используют обучаемые позиционные эмбеддинги. Они работают лучше для фиксированной максимальной длины, но хуже обобщаются на более длинные последовательности.

Собираем весь трансформер по кусочкам

Теперь у нас есть все компоненты. Давайте соберем полноценный трансформер-энкодер (для простоты начнем с него, без декодера).

class TransformerEncoderLayer:
    def __init__(self, d_model, num_heads, d_ff, dropout=0.1):
        self.self_attn = MultiHeadAttention(d_model, num_heads)
        self.feed_forward = FeedForward(d_model, d_ff)
        self.norm1 = LayerNorm(d_model)
        self.norm2 = LayerNorm(d_model)
        self.dropout = Dropout(dropout)
        
    def forward(self, x, mask=None):
        # Self-attention с residual connection
        attn_output, _ = self.self_attn(x, x, x, mask)
        x = x + self.dropout(attn_output)
        x = self.norm1(x)
        
        # Feed-forward с residual connection
        ff_output = self.feed_forward(x)
        x = x + self.dropout(ff_output)
        x = self.norm2(x)
        
        return x

Обратите внимание на residual connections (x + something). Без них глубокие сети не обучаются — градиенты исчезают через 5-6 слоев.

4 Реализуем LayerNorm: нормализация, которая работает

BatchNorm плохо работает с последовательностями разной длины. LayerNorm нормализует по фичам, а не по батчу.

class LayerNorm:
    def __init__(self, dim, eps=1e-5):
        self.gamma = Tensor(np.ones(dim), requires_grad=True)
        self.beta = Tensor(np.zeros(dim), requires_grad=True)
        self.eps = eps
        
    def forward(self, x):
        # x: [batch, seq_len, dim]
        mean = x.mean(axis=-1, keepdims=True)
        std = x.std(axis=-1, keepdims=True)
        
        normalized = (x - mean) / (std + self.eps)
        return normalized * self.gamma + self.beta

Обучаем эту штуку: backpropagation без фреймворков

Самое интересное — обучение. У нас есть вычислительный граф, у нас есть функция потерь. Как заставить градиенты течь?

class Optimizer:
    def __init__(self, parameters, lr=0.001):
        self.parameters = [p for p in parameters if p.requires_grad]
        self.lr = lr
        
    def zero_grad(self):
        """Обнуляем градиенты перед каждым шагом"""
        for p in self.parameters:
            p.grad = np.zeros_like(p.grad)
            
    def step(self):
        """Делаем шаг градиентного спуска"""
        for p in self.parameters:
            p.data -= self.lr * p.grad

def backward(tensor):
    """Вычисляем градиенты для всего графа"""
    # Топологическая сортировка графа
    topo = []
    visited = set()
    
    def build_topo(v):
        if v not in visited:
            visited.add(v)
            for child in v._children:
                build_topo(child)
            topo.append(v)
    
    build_topo(tensor)
    
    # Инициализируем градиент выходного тензора
    tensor.grad = np.ones_like(tensor.data)
    
    # Проходим граф в обратном порядке
    for v in reversed(topo):
        v._backward()

Магия в функции backward. Она:

  1. Строит топологический порядок графа (чтобы каждый узел обрабатывался после своих детей)
  2. Устанавливает градиент выходного тензора в 1 (dL/dL = 1)
  3. Вызывает _backward для каждого узла в обратном порядке

Самая частая ошибка: забыть вызвать zero_grad() перед каждым шагом. Градиенты аккумулируются, и вы получаете градиенты от нескольких батчей одновременно.

Тренируем на реальной задаче: копирование последовательностей

Давайте проверим, работает ли наша реализация на простой задаче: копирование последовательности.

# Создаем простой датасет: случайные последовательности чисел
def generate_batch(batch_size, seq_len, vocab_size):
    src = np.random.randint(1, vocab_size, (batch_size, seq_len))
    tgt = src.copy()  # задача: скопировать вход
    return Tensor(src), Tensor(tgt)

# Инициализируем модель
model = TransformerEncoder(vocab_size=50, d_model=128, num_heads=8, 
                          num_layers=3, d_ff=512)
optimizer = Optimizer(model.parameters(), lr=0.001)

# Цикл обучения
for epoch in range(100):
    total_loss = 0
    
    for _ in range(100):  # 100 батчей за эпоху
        src, tgt = generate_batch(32, 10, 50)
        
        # Forward pass
        output = model(src)
        loss = cross_entropy_loss(output, tgt)
        
        # Backward pass
        optimizer.zero_grad()
        backward(loss)
        optimizer.step()
        
        total_loss += loss.data
        
    print(f"Epoch {epoch}, Loss: {total_loss / 100:.4f}")

Если все сделано правильно, loss должен уменьшаться. Если нет — где-то ошибка в вычислении градиентов.

Что может пойти не так: отладка собственного трансформера

Когда вы пишете все с нуля, ошибки неизбежны. Вот самые частые проблемы:

Проблема Симптомы Решение
Исчезающие градиенты Loss не меняется, градиенты близки к нулю Проверить инициализацию весов, добавить residual connections
Взрывающиеся градиенты Loss становится NaN, градиенты огромные Добавить gradient clipping, уменьшить learning rate
Плохая сходимость Loss скачет, не сходится Проверить реализацию LayerNorm, добавить warmup для LR
Утечки памяти Память растет с каждой итерацией Убедиться, что не сохраняете лишние ссылки на тензоры

Для отладки градиентов реализуйте численную проверку:

def gradient_check(module, input_tensor, eps=1e-5):
    """Сравниваем аналитические градиенты с численными"""
    # Аналитический градиент
    output = module.forward(input_tensor)
    loss = output.sum()
    loss.backward()
    
    analytical_grad = module.weight.grad.copy()
    
    # Численный градиент
    numerical_grad = np.zeros_like(module.weight.data)
    
    for i in range(module.weight.data.size):
        original = module.weight.data.flat[i]
        
        # f(x + eps)
        module.weight.data.flat[i] = original + eps
        output_plus = module.forward(input_tensor).sum().data
        
        # f(x - eps)
        module.weight.data.flat[i] = original - eps
        output_minus = module.forward(input_tensor).sum().data
        
        # Возвращаем исходное значение
        module.weight.data.flat[i] = original
        
        # Центральная разностная схема
        numerical_grad.flat[i] = (output_plus - output_minus) / (2 * eps)
    
    # Сравниваем
    diff = np.abs(analytical_grad - numerical_grad).max()
    print(f"Max gradient difference: {diff}")
    return diff < 1e-4

Зачем все это нужно в 2026 году?

Казалось бы, зачем писать трансформер с нуля, когда есть Transformers v5 и другие готовые решения?

Потому что понимание — это суперсила. Когда вы знаете, как работает каждый компонент:

  • Вы можете модифицировать архитектуру под свои нужды (как в Differential Transformer V2)
  • Вы можете писать свои оптимизации, вплоть до CUDA-ядер (как в статье про генерацию CUDA-кода)
  • Вы понимаете, почему модель ведет себя странно, и можете это починить
  • Вы не боитесь читать исходный код современных фреймворков

Кстати, если хотите увидеть, как собирают GPT с нуля на PyTorch, посмотрите эту статью. Разница в подходе поразительна.

Что дальше? От учебного кода к production

Наша реализация — учебная. Она медленная, неоптимизированная, но зато понятная. Что нужно сделать, чтобы превратить ее в production-ready?

  1. Добавить поддержку GPU через CuPy или написать свои CUDA-ядра
  2. Реализовать mixed precision training (FP16/FP32)
  3. Добавить checkpointing для сохранения моделей
  4. Оптимизировать память с помощью gradient checkpointing
  5. Реализовать распределенное обучение

Но самое главное — вы теперь понимаете, что происходит под капотом. И когда в следующий раз будете использовать готовую библиотеку, вы будете знать, какие матрицы там перемножаются и какие градиенты текут.

Попробуйте добавить декодер. Или реализовать разные виды внимания (sliding window, sparse attention). Или придумать свою архитектуру. Теперь у вас есть фундамент.

А если хотите готовые промпты для экспериментов с моделями, посмотрите эту подборку.