Почему вам нужна эта боль? Или зачем писать трансформер на чистом 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.), мы создаем новый тензор, который знает, как его создали и кто его родители.
2 Реализуем базовые операции
Добавляем поддержку основных математических операций. Каждая операция должна:
- Вычислить результат
- Создать новый тензор с ссылкой на родителей
- Определить функцию для обратного распространения
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 (dL/dL = 1)
- Вызывает
_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?
- Добавить поддержку GPU через CuPy или написать свои CUDA-ядра
- Реализовать mixed precision training (FP16/FP32)
- Добавить checkpointing для сохранения моделей
- Оптимизировать память с помощью gradient checkpointing
- Реализовать распределенное обучение
Но самое главное — вы теперь понимаете, что происходит под капотом. И когда в следующий раз будете использовать готовую библиотеку, вы будете знать, какие матрицы там перемножаются и какие градиенты текут.
Попробуйте добавить декодер. Или реализовать разные виды внимания (sliding window, sparse attention). Или придумать свою архитектуру. Теперь у вас есть фундамент.
А если хотите готовые промпты для экспериментов с моделями, посмотрите эту подборку.