Глубокое погружение в Transformer

В 2017 году Google опубликовала статью «Attention Is All You Need», представив архитектуру Transformer. Никто не ожидал, что эта статья полностью изменит область ИИ.

Сегодняшние GPT, Claude, Gemini, Llama... почти все основные большие языковые модели по сути являются вариантами Transformer.

Поймите Transformer — и вы поймёте 90% современных LLM.

В этой статье мы погрузимся в каждый компонент Transformer: самовнимание, многоголовое внимание, позиционное кодирование, сети прямого распространения, нормализацию по слоям... рассматривая не только «что», но и «почему».

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


Предыстория Transformer: ограничения RNN

До Transformer задачи обработки последовательностей (такие как перевод и генерация текста) в основном решались с помощью RNN (рекуррентных нейронных сетей) и их вариантов LSTM и GRU.

Как работает RNN

Основная идея RNN — «обрабатывать по одному»: входная последовательность поступает в сеть слово за словом, и выход на каждом шаге содержит информацию из всех предыдущих слов.

Пример

# ============================================
# Упрощённая демонстрация прямого распространения RNN
# ============================================

import math


class SimpleRNN:
    """Упрощённая реализация RNN для демонстрации принципа"""

    def __init__(self, input_size: int, hidden_size: int):
        """Инициализация параметров RNN"""
        import random
        random.seed(42)  # Задать случайное зерно для воспроизводимости

        # Веса от входа к скрытому слою
        self.Wx = [[random.uniform(-0.1, 0.1) for _ in range(hidden_size)]
                   for _ in range(input_size)]
        # Веса от скрытого слоя к скрытому слою
        self.Wh = [[random.uniform(-0.1, 0.1) for _ in range(hidden_size)]
                   for _ in range(hidden_size)]
        # Смещение
        self.b = [0.0 for _ in range(hidden_size)]

    def step(self, x: list, h_prev: list) -> list:
        """Одношаговая RNN: принимает вход x и предыдущее скрытое состояние h_prev, возвращает новое скрытое состояние"""
        hidden_size = len(h_prev)
        h_new = [0.0 for _ in range(hidden_size)]

        # h_new = tanh(Wx·x + Wh·h_prev + b)
        for i in range(hidden_size):
            # Вычислить Wx·x
            wx_sum = sum(x[j] * self.Wx[j][i] for j in range(len(x)))
            # Вычислить Wh·h_prev
            wh_sum = sum(h_prev[j] * self.Wh[j][i] for j in range(hidden_size))
            # Добавить смещение, пропустить через tanh
            h_new[i] = math.tanh(wx_sum + wh_sum + self.b[i])

        return h_new

    def forward(self, sequence: list) -> list:
        """Обработать всю последовательность"""
        hidden_size = len(self.b)
        h = [0.0 for _ in range(hidden_size)]  # Начальное скрытое состояние
        hidden_states = []

        for x in sequence:
            h = self.step(x, h)
            hidden_states.append(h)

        return hidden_states


# Тест: демонстрация с помощью простой векторной последовательности
input_size = 4
hidden_size = 3

rnn = SimpleRNN(input_size, hidden_size)

# Предположим, входная последовательность состоит из 4 слов, каждое представлено 4-мерным вектором
sequence = [
    [1.0, 0.0, 0.0, 0.0],  # Слово 1
    [0.0, 1.0, 0.0, 0.0],  # Слово 2
    [0.0, 0.0, 1.0, 0.0],  # Слово 3
    [0.0, 0.0, 0.0, 1.0],  # Слово 4
]

hidden_states = rnn.forward(sequence)

print("Пример демонстрации Simple RNN")
print("=" * 40)
for i, h in enumerate(hidden_states):
    print(f"Скрытое состояние на шаге {i+1}: {[f'{v:.4f}' for v in h]}")
# Вывод:
# Пример демонстрации простой RNN
# ========================================
# Скрытое состояние на шаге 1: ['0.0204', '-0.0434', '0.0556']
# Скрытое состояние на шаге 2: ['-0.0061', '-0.0529', '0.0775']
# Скрытое состояние на шаге 3: ['0.0641', '-0.0409', '0.0655']
# Скрытое состояние на шаге 4: ['0.0143', '-0.0805', '0.0277']

RNN кажется разумным, но у него есть три фатальные проблемы:

Проблема 1: исчезающие градиенты, сложность запоминания долгосрочных зависимостей

RNN — это структура типа «цепь»; градиент на каждом шаге должен распространяться обратно к первому шагу. После многократных умножений градиент экспоненциально затухает, становясь почти 0.

Например, в предложении «В 2010 году я поехал в Париж, ... это был мой любимый город» — «город» должен относиться к «Парижу», но между ними слишком много слов, поэтому RNN с трудом обучаются таким долгосрочным зависимостям.

LSTM и GRU смягчают эту проблему, но не решают её полностью.

Проблема 2: невозможность параллельных вычислений, медленное обучение

RNN должна дождаться завершения шага t-1, прежде чем вычислить шаг t. Это означает:

  • Даже с 100 GPU можно вычислить только одно слово за раз
  • Чем длиннее последовательность, тем больше время обучения
  • Сложно масштабировать на очень большие наборы данных

Проблема 3: ранняя информация легко «перезаписывается»

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

Появление Transformer решило все три проблемы одновременно.

ХарактеристикаRNN/LSTMТрансформер
Метод вычисленийПоследовательно, шаг за шагомПараллельно, всё вычисляется сразу
Дальние зависимостиСлабый, исчезающий градиентСильная прямая связь на любом расстоянии
Позиционная информацияЕстественно последовательныйТребует позиционного кодирования
Скорость обученияМедленнаяБыстрая (распараллеливаемая)

Механизм самовнимания

Самовнимание — ядро Transformer. Его идея проста:Каждое слово должно "общаться" со всеми словами в предложении, определить, кто важен для него, а затем выполнить взвешенное суммирование по важности.

Сначала посмотрим на общую схему архитектуры:

Интуитивное понимание Q, K, V

Самовнимание использует три вектора для описания каждого слова:

  • Q (запрос)——«Кого я ищу?»—— на какое слово это слово хочет обратить внимание
  • K (ключ)——«Кто я?»—— идентичность этого слова
  • V (значение)——«Что у меня есть?»—— фактическое содержимое этого слова

Процесс вычисления таков:

  1. Каждое слово использует свой Q, чтобы «сопоставить» его с K всех слов, получая оценки внимания
  2. Используйте Softmax, чтобы нормализовать оценки так, чтобы они в сумме давали 1
  3. Используйте нормализованные оценки, чтобы взвесить и просуммировать V всех слов

Диаграмма делает это более наглядным:

自注意力 QKV 计算流程图

Формула вычисления оценки внимания

Полная формула такова:

Attention(Q, K, V) = softmax( Q·Kᵀ / √dₖ ) · V

Давайте реализуем это шаг за шагом на чистом Python:

Пример

# ============================================
# Реализация Self-Attention на чистом Python
# ============================================

import math


def softmax(x: list) -> list:
    """Вычислить Softmax: преобразовать набор чисел в распределение вероятностей, сумма которого равна 1"""
    # Вычитаем максимальное значение, чтобы предотвратить числовое переполнение
    max_val = max(x)
    exp_x = [math.exp(v - max_val) for v in x]
    sum_exp = sum(exp_x)
    return [v / sum_exp for v in exp_x]


def matrix_multiply(A: list, B: list) -> list:
    """Умножение матриц: A имеет размер m×n, B — n×p, результат — m×p"""
    m = len(A)
    n = len(B)
    p = len(B[0])

    result = [[0.0 for _ in range(p)] for _ in range(m)]
    for i in range(m):
        for j in range(p):
            for k in range(n):
                result[i][j] += A[i][k] * B[k][j]
    return result


def transpose(matrix: list) -> list:
    """Транспонирование матрицы"""
    return list(map(list, zip(*matrix)))


class SelfAttention:
    """Реализация слоя самовнимания на чистом Python"""

    def __init__(self, d_model: int, d_k: int):
        """
Инициализация слоя самовнимания
d_model: размерность входа/выхода
d_k: размерность Q/K (обычно d_k = d_model / num_heads)
        """

        import random
        random.seed(42)  # пример: фиксированное зерно генератора случайных чисел

        self.d_model = d_model
        self.d_k = d_k

        # Инициализация матриц проекций для Q, K, V
        # На практике они обучаются
        self.Wq = [[random.normalvariate(0, 0.1) for _ in range(d_k)]
                   for _ in range(d_model)]
        self.Wk = [[random.normalvariate(0, 0.1) for _ in range(d_k)]
                   for _ in range(d_model)]
        self.Wv = [[random.normalvariate(0, 0.1) for _ in range(d_k)]
                   for _ in range(d_model)]

    def forward(self, X: list) -> list:
        """
Прямое распространение self-attention
X: входная последовательность, форма [seq_len, d_model]
Возвращает: выходная последовательность, форма [seq_len, d_k]
        """

        seq_len = len(X)

        # Шаг 1: Вычислить Q, K, V
        # Q = X·Wq, K = X·Wk, V = X·Wv
        Q = matrix_multiply(X, self.Wq)
        K = matrix_multiply(X, self.Wk)
        V = matrix_multiply(X, self.Wv)

        print(" Форма Q:", len(Q), "×", len(Q[0]))
        print(" Форма K:", len(K), "×", len(K[0]))
        print(" Форма V:", len(V), "×", len(V[0]))

        # Шаг 2: Вычисление оценок внимания Q·Kᵀ
        K_transposed = transpose(K)
        scores = matrix_multiply(Q, K_transposed)

        print("\nИсходные оценки внимания:")
        for i, row in enumerate(scores):
            print(fСтрока {i}: {[f'{v:.4f}' for v in row]})

        # Шаг 3: Масштабирование: разделить на √d_k
        # Это нужно, чтобы Softmax не попадал в область насыщения градиента.
        scale_factor = math.sqrt(self.d_k)
        scores_scaled = [[v / scale_factor for v in row] for row in scores]

        print("\nМасштабированные оценки внимания:)
        for i, row in enumerate(scores_scaled):
            print(fСтрока {i}: {[f'{v:.4f}' for v in row]})

        # Шаг 4: Нормализация Softmax
        attention_weights = []
        for row in scores_scaled:
            attention_weights.append(softmax(row))

        print("\nВеса внимания (после Softmax): ")
        for i, row in enumerate(attention_weights):
            print(fСтрока {i}: {[f'{v:.4f}' for v in row]})

        # Шаг 5: Взвешенная сумма V
        output = matrix_multiply(attention_weights, V)

        return output, attention_weights


# ============================================
# Демонстрация: запуск self-attention на простом примере
# ============================================

# Предположим, у нас есть предложение из 4 слов, каждое слово представлено 8-мерным вектором
# Для демонстрации мы используем one-hot + небольшой случайный шум
d_model = 8
d_k = 6

# Входная последовательность: 4 слова, каждое 8-мерное
X = [
    [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],  # Слово 0
    [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],  # Слово 1
    [0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],  # Слово 2
    [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0],  # Слово 3
]

print("=" * 50)
print("Пример демонстрации Self-Attention")
print("=" * 50)
print(f"Длина входной последовательности: {len(X)}")
print(f"Входная размерность: {len(X)
print()

attn = SelfAttention(d_model=d_model, d_k=d_k)
output, attention_weights = attn.forward(X)

print("\n" + "=" * 50)
print("Выход самовнимания:")
print("=" * 50)
for i, row in enumerate(output):
    print(f"Выход для слова {i}: {[f'{v:.4f}' for v in row]}")

Запустите этот код, и вы увидите полный процесс вычислений: генерацию Q/K/V, вычисление оценок внимания, масштабирование, Softmax и, наконец, взвешенный выход.

Почему делить на √dₖ?

Это важная деталь. Если предположить, что каждый элемент Q и K — случайная величина со средним 0 и дисперсией 1, то дисперсия их скалярного произведения равна dₖ.

Если dₖ велико (например, 512), значения скалярного произведения становятся очень большими, из-за чего Softmax попадает в "область насыщения" — градиент почти равен 0, и обучение не может продолжаться.

После деления на √dₖ дисперсия возвращается к 1, и градиент Softmax снова становится нормальным.

Масштабированное внимание на основе скалярного произведения — один из ключевых приёмов для стабильного обучения Transformer.


Многоголовое внимание

Одноголовое самовнимание мощно, но у него есть одна проблема: каждое слово может «обратить внимание» только один раз. Что, если мы хотим, чтобы слово обращало внимание на несколько разных аспектов одновременно?

Например, в предложении «В зоопарке тигр гонится за кроликом»:

  • «Тигр» может нуждаться в одной голове, чтобы обращать внимание на «зоопарк» (местоположение)
  • Другая голова — чтобы обращать внимание на «кролика» (объект действия)
  • Третья голова сосредоточена на «преследовании» (действии).

Многоголовое внимание разбивает Q/K/V на несколько «голов», каждая из которых изучает свой паттерн внимания, и в конце объединяет их.

Шаги вычисления многоголового внимания

  1. Разделите Q/K/V на h частей по последней размерности (h — число голов).
  2. Каждая голова независимо выполняет самовнимание
  3. Объедините выходы всех голов
  4. Пропустите через линейный проекционный слой, чтобы получить итоговый выход

Формула:

MultiHead(Q, K, V) = Concat(head₁, head₂, ..., headₕ) · Wₒ
  其中 headᵢ = Attention(Q·Wqᵢ, K·Wkᵢ, V·Wvᵢ)

Пример

# ============================================
# Реализация Multi-Head Attention на чистом Python
# ============================================

import math
import random
random.seed(42)  # пример: фиксированное зерно генератора случайных чисел


def softmax(x: list) -> list:
    """Вычисление Softmax"""
    max_val = max(x)
    exp_x = [math.exp(v - max_val) for v in x]
    sum_exp = sum(exp_x)
    return [v / sum_exp for v in exp_x]


def matrix_multiply(A: list, B: list) -> list:
    """Умножение матриц"""
    m = len(A)
    n = len(B)
    p = len(B[0])
    result = [[0.0 for _ in range(p)] for _ in range(m)]
    for i in range(m):
        for j in range(p):
            for k in range(n):
                result[i][j] += A[i][k] * B[k][j]
    return result


def transpose(matrix: list) -> list:
    """Транспонирование матрицы"""
    return list(map(list, zip(*matrix)))


class MultiHeadAttention:
    """Реализация слоя многоголового внимания на чистом Python"""

    def __init__(self, d_model: int, num_heads: int):
        """
Инициализация многоголового внимания
d_model: размерность модели (должна делиться на num_heads)
num_heads: количество голов внимания
        """

        assert d_model % num_heads == 0, "d_model должен делиться на num_heads"

        self.d_model = d_model
        self.num_heads = num_heads
        self.d_k = d_model // num_heads  # размерность каждой головы

        # инициализация матриц проекций
        self.Wq = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
                   for _ in range(d_model)]
        self.Wk = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
                   for _ in range(d_model)]
        self.Wv = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
                   for _ in range(d_model)]
        self.Wo = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
                   for _ in range(d_model)]

    def split_heads(self, X: list) -> list:
        """
Разделить последнее измерение на num_heads голов
Форма входа: [seq_len, d_model]
Форма выхода: [num_heads, seq_len, d_k]
        """

        seq_len = len(X)
        # Изменение формы: [seq_len, num_heads, d_k]
        reshaped = []
        for row in X:
            head_rows = []
            for h in range(self.num_heads):
                start = h * self.d_k
                end = start + self.d_k
                head_rows.append(row[start:end])
            reshaped.append(head_rows)
        # Транспонирование: [num_heads, seq_len, d_k]
        result = []
        for h in range(self.num_heads):
            head_data = [reshaped[i][h] for i in range(seq_len)]
            result.append(head_data)
        return result

    def combine_heads(self, heads: list) -> list:
        """
Объединить головы обратно
Форма входа: [num_heads, seq_len, d_k]
Форма выхода: [seq_len, d_model]
        """

        seq_len = len(heads[0])
        result = []
        for i in range(seq_len):
            combined = []
            for h in range(self.num_heads):
                combined.extend(heads[h][i])
            result.append(combined)
        return result

    def attention_single_head(self, Q: list, K: list, V: list) -> list:
        """Одноголовое самовнимание"""
        # Q·K^T
        scores = matrix_multiply(Q, transpose(K))
        # Масштабирование
        scale = math.sqrt(self.d_k)
        scores_scaled = [[v / scale for v in row] for row in scores]
        # Softmax
        weights = [softmax(row) for row in scores_scaled]
        # Взвешенная сумма V
        output = matrix_multiply(weights, V)
        return output, weights

    def forward(self, X: list) -> list:
        """Прямой проход многоголового внимания"""
        seq_len = len(X)

        # Шаг 1: Вычислить Q, K, V
        Q = matrix_multiply(X, self.Wq)
        K = matrix_multiply(X, self.Wk)
        V = matrix_multiply(X, self.Wv)

        # Шаг 2: Разделение на несколько голов
        Q_heads = self.split_heads(Q)
        K_heads = self.split_heads(K)
        V_heads = self.split_heads(V)

        # Шаг 3: Каждая голова независимо выполняет самовнимание
        output_heads = []
        all_weights = []
        for h in range(self.num_heads):
            out, weights = self.attention_single_head(
                Q_heads[h], K_heads[h], V_heads[h]
            )
            output_heads.append(out)
            all_weights.append(weights)

        # Шаг 4: Объединить все головы
        combined = self.combine_heads(output_heads)

        # Шаг 5: Финальная линейная проекция
        output = matrix_multiply(combined, self.Wo)

        return output, all_weights


# ============================================
# Демонстрация многоголового внимания
# ============================================

d_model = 12
num_heads = 3  # 12 / 3 = 4 размерности на голову

# Входная последовательность: 4 токена, каждый с 12 размерностями
X = [
    [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
    [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
    [0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
    [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
]

print("=" * 50)
print("Пример демонстрации многоголового внимания")
print("=" * 50)
print(f"d_model = {d_model}, num_heads = {num_heads}")
print(f"Размерность на голову: d_k = {d_model // num_heads}")
print()

mha = MultiHeadAttention(d_model=d_model, num_heads=num_heads)
output, all_weights = mha.forward(X)

print("\n"Веса внимания для каждой головы:")
for h in range(num_heads):
    print(f"\n"Голова {h}:")
    for i, row in enumerate(all_weights[h]):
        print(f" Позиция {i}: {[f'{v:.4f}' for v in row]}")

print("\n" + "=" * 50)
print("Финальная форма выхода многоголового внимания:", len(output), "×", len(output[0]))
print("=" * 50)
for i, row in enumerate(output):
    print(f"Позиция {i}: {[f'{v:.4f}' for v in row[:6]]}...")

Сила многоголового внимания заключается в следующем:Разные головы автоматически изучают разные типы паттернов внимания.

Например, в задачах перевода:

  • Некоторые головы фокусируются на синтаксических связях (подлежащее–сказуемое–дополнение)
  • Некоторые головы фокусируются на кореференции (к какому существительному относится местоимение)
  • Некоторые головы фокусируются на локальных соседних словах

Эти паттерны не проектируются вручную; они автоматически изучаются моделью во время обучения.


Позиционное кодирование

У самовнимания есть проблема: это модель «мешка слов» — она не заботится о порядке слов. «Я люблю тебя» и «Ты любишь меня» могут выглядеть для самовнимания одинаково.

Но порядок критически важен для языка. Нам нужно «внедрить» позиционную информацию в модель.

Синусоидальное позиционное кодирование

Оригинальный Transformer использует синусоидальное позиционное кодирование:

PE(pos, 2i) = sin(pos / 10000^(2i/d_model))
PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))

Где:

  • pos — это позиция (0, 1, 2, ...)
  • i — это индекс размерности
  • d_model — это размерность модели

Эта конструкция остроумна:

  • Каждая позиция имеет уникальное кодирование
  • Относительные позиции можно представить в виде линейных комбинаций (sin(a+b) = sin a cos b + cos a sin b)
  • Оно может экстраполировать на более длинные последовательности, чем те, что встречались при обучении

Пример

# ============================================
# Реализовать синусоидальное позиционное кодирование
# ============================================

import math


def sinusoidal_positional_encoding(seq_len: int, d_model: int) -> list:
    """
Вычислить синусоидальное позиционное кодирование
Возвращает: матрица позиционного кодирования формы [seq_len, d_model]
    """

    pe = [[0.0 for _ in range(d_model)] for _ in range(seq_len)]

    for pos in range(seq_len):
        for i in range(0, d_model, 2):
            # Использовать sin для чётных размерностей
            div_term = math.pow(10000, 2 * i / d_model)
            pe[pos][i] = math.sin(pos / div_term)

            # Использовать cos для нечётных размерностей (если следующая размерность существует)
            if i + 1 < d_model:
                pe[pos][i + 1] = math.cos(pos / div_term)

    return pe


# Демонстрация: генерация позиционного кодирования
seq_len = 10
d_model = 8

pe = sinusoidal_positional_encoding(seq_len, d_model)

print("=" * 50)
print("Пример демонстрации синусоидального позиционного кодирования")
print("=" * 50)
print(f"Длина последовательности: {seq_len}, Размерность: {d_model}")
print()
for pos in range(seq_len):
    print(f"Позиция {pos:2d}: {[f'{v:.4f}' for v in pe[pos]]}")

# Изучение связи относительных позиций
print("\n" + "=" * 50)
print("Проверка линейной зависимости относительных позиций")
print("=" * 50)
print("Разница кодирования между позицией 0 и позицией 1:")
diff = [pe[1][i] - pe[0][i] for i in range(d_model)]
print([f'{v:.4f}' for v in diff])

RoPE: вращательное позиционное кодирование

Хотя синусоидальное позиционное кодирование классическое, позже появились лучшие решения. Современные основные крупные модели (такие как LLaMA) используютRoPE(Rotary Positional Embedding)。

Идея RoPE состоит в следующем:вращать информацию о позиции в Query и Key.

В частности, повернуть каждую пару измерений Q и K:

[ q₁, q₂, q₃, q₄, ... ]
  ↓ 旋转
[ q₁·cos mθ - q₂·sin mθ,
  q₁·sin mθ + q₂·cos mθ,
  q₃·cos mθ' - q₄·sin mθ',
  q₃·sin mθ' + q₄·cos mθ',
  ... ]

Преимущества RoPE:

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

ALiBi: затухающее внимание по расстоянию

Другой простой и эффективный подход — этоALiBi(Attention with Linear Biases)。

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

Преимущество ALiBi — простота, и оно особенно хорошо подходит для экстраполяции длины.

Методы позиционного кодированияПринципПреимуществаРепрезентативные модели
Синусоидальное позиционное кодированиефункции sin/cosЭкстраполируемость, простое вычислениеОригинальный Transformer, BERT
Обучаемое позиционное кодированиеНапрямую обучать эмбеддингВысокая выразительная способностьGPT-1/2
RoPEВращение Q/KОтносительные позиции, хорошая производительностьLLaMA, PaLM
ALiBiДобавляет смещение к оценкам вниманияПросто, хорошо подходит для длинных последовательностейMPT, BLOOM

Слой нейронной сети прямого распространения (FFN)

В каждом слое Transformer после внимания также есть сеть прямого распространения.

Структура FFN

FFN оригинального Transformer имеет вид:

FFN(x) = max(0, x·W₁ + b₁) · W₂ + b₂

То есть: линейный слой → активация ReLU → линейный слой.

Обычно промежуточная размерность больше входной (например, d_model=512, промежуточная размерность=2048).

ReLU против SwiGLU

Изначально использовался ReLU, но сейчас мейнстримом являетсяSwiGLU:

SwiGLU(x) = (x·W₁ ⊗ sigmoid(x·V₁)) · W₂

где ⊗ — поэлементное умножение.

SwiGLU показывает лучшие результаты, чем ReLU, на многих задачах; LLaMA, PaLM и др. — все используют его.

Почему FFN важен?

  • Attention — это «агрегация информации», FFN — это «обработка информации»
  • FFN имеет большое количество параметров и является основным местом, где модель «запоминает» знания
  • Без FFN выразительная способность Transformer была бы сильно снижена

Пример

# ============================================
# Реализация FFN: версия ReLU и версия SwiGLU
# ============================================

import math
import random
random.seed(42)  # пример


def relu(x: float) -> float:
    """Функция активации ReLU: max(0, x)"""
    return max(0.0, x)


def sigmoid(x: float) -> float:
    """Функция активации Sigmoid"""
    if x < -50:  # Предотвращение численного переполнения
        return 0.0
    return 1.0 / (1.0 + math.exp(-x))


def linear(x: list, W: list, b: list) -> list:
    """Линейный слой: y = x·W + b"""
    output_size = len(b)
    output = [0.0 for _ in range(output_size)]

    for i in range(output_size):
        sum_wx = sum(x[j] * W[j][i] for j in range(len(x)))
        output[i] = sum_wx + b[i]

    return output


class FFNReLU:
    """Версия FFN с ReLU"""

    def __init__(self, d_model: int, d_ff: int):
        self.d_model = d_model
        self.d_ff = d_ff

        # Случайная инициализация параметров (на практике получаются обучением)
        self.W1 = [[random.normalvariate(0, 0.1) for _ in range(d_ff)]
                   for _ in range(d_model)]
        self.b1 = [0.0 for _ in range(d_ff)]

        self.W2 = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
                   for _ in range(d_ff)]
        self.b2 = [0.0 for _ in range(d_model)]

    def forward(self, x: list) -> list:
        """Прямой проход"""
        # Первый слой: линейный + ReLU
        hidden = linear(x, self.W1, self.b1)
        hidden = [relu(v) for v in hidden]

        # Второй слой: линейный
        output = linear(hidden, self.W2, self.b2)
        return output


class FFNSwiGLU:
    """SwiGLU-версия FFN (более современный выбор)"""

    def __init__(self, d_model: int, d_ff: int):
        self.d_model = d_model
        self.d_ff = d_ff

        # SwiGLU требует два линейных слоя для гейтирования, плюс выходной слой
        self.W1 = [[random.normalvariate(0, 0.1) for _ in range(d_ff)]
                   for _ in range(d_model)]
        self.V1 = [[random.normalvariate(0, 0.1) for _ in range(d_ff)]
                   for _ in range(d_model)]
        self.W2 = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
                   for _ in range(d_ff)]

    def forward(self, x: list) -> list:
        """Прямой проход: SwiGLU"""
        # Вычислить два линейных преобразования
        x1 = linear(x, self.W1, [0.0 for _ in range(self.d_ff)])
        x2 = linear(x, self.V1, [0.0 for _ in range(self.d_ff)])

        # SwiGLU:x1 * sigmoid(x2)
        hidden = [a * sigmoid(b) for a, b in zip(x1, x2)]

        # Выходной слой
        output = linear(hidden, self.W2, [0.0 for _ in range(self.d_model)])
        return output


# Демонстрация FFN
d_model = 8
d_ff = 16

# Тестовый ввод
x = [0.1, -0.2, 0.3, -0.4, 0.5, -0.6, 0.7, -0.8]

print("=" * 50)
print("Пример демонстрации FFN")
print("=" * 50)
print(f"Ввод: {[f'{v:.4f}' for v in x]}")

ffn_relu = FFNReLU(d_model, d_ff)
output_relu = ffn_relu.forward(x)
print(f"\n"Выход ReLU FFN: {[f'{v:.4f}' for v in output_relu]}")

ffn_swiglu = FFNSwiGLU(d_model, d_ff)
output_swiglu = ffn_swiglu.forward(x)
print(f"Выход SwiGLU FFN: {[f'{v:.4f}' for v in output_swiglu]}")

Нормализация слоя (Layer Norm)

В Transformer есть ещё один важный компонент: нормализация слоя.

Что делает нормализация слоя

Нормализация слоя нормализует признаки каждого образца к среднему 0 и дисперсии 1:

LN(x) = γ ⊗ ((x - μ) / √σ²) + β

где:

  • μ — среднее x, σ² — дисперсия x
  • γ (масштаб) и β (сдвиг) — обучаемые параметры
  • ⊗ — поэлементное умножение

Пример

# ============================================
# Реализация нормализации слоя
# ============================================

import math


def layer_norm(x: list, gamma: list, beta: list, eps: float = 1e-6) -> list:
    """
Нормализация слоя
x: входной вектор
gamma: параметр масштабирования
beta: параметр смещения
eps: небольшая константа для предотвращения деления на ноль
    """

    # Вычислить среднее
    mean = sum(x) / len(x)

    # Вычислить дисперсию
    var = sum((v - mean) ** 2 for v in x) / len(x)

    # Нормализация + масштабирование и сдвиг
    normalized = []
    for xi, gi, bi in zip(x, gamma, beta):
        normalized.append(gi * (xi - mean) / math.sqrt(var + eps) + bi)

    return normalized


# Демонстрация нормализации слоя
d_model = 6

# Входной вектор
x = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]

# gamma инициализирована значением 1, beta инициализирована значением 0
gamma = [1.0 for _ in range(d_model)]
beta = [0.0 for _ in range(d_model)]

print("=" * 50)
print("Пример демонстрации нормализации слоя")
print("=" * 50)
print(f"Ввод: {[f'{v:.4f}' for v in x]}")

output = layer_norm(x, gamma, beta)
print(f"Вывод: {[f'{v:.4f}' for v in output]}")

# Проверка среднего и дисперсии
mean_out = sum(output) / len(output)
var_out = sum((v - mean_out) ** 2 for v in output) / len(output)
print(f"\n"Среднее выходных данных: {mean_out:.4f}")
print(f"Дисперсия выходных данных: {var_out:.4f}")

Pre-Norm против Post-Norm

Нормализация слоя имеет два способа размещения:

  • Post-Norm: подслой → остаточная связь → нормализация (оригинальный Transformer)
  • Pre-Norm: нормализация → подслой → остаточная связь (теперь основной подход)

Обучение с Pre-Norm более стабильно, и теперь его используют почти все большие модели.

RMS Norm

RMS NormЭто упрощённая версия нормализации слоя, вычисляющая только среднеквадратичное значение, без вычитания среднего:

RMS(x) = sqrt(mean(x²))
RMSNorm(x) = γ ⊗ (x / RMS(x))

RMS Norm вычисляется быстрее, при схожих результатах; модели вроде LLaMA используют её.


Архитектура Encoder-Decoder против Decoder-Only

Оригинальный Transformer — это архитектура Encoder-Decoder, но большинство больших языковых моделей сегодня — Decoder-Only.

Encoder-Decoder: используется для таких задач, как перевод

Архитектура Encoder-Decoder:

  • Encoder: читает входную последовательность (например, предложение на исходном языке) и генерирует контекстное представление
  • Decoder: генерирует целевую последовательность (например, результат перевода) на основе выхода Encoder

В Decoder есть два типа внимания:

  1. Маскированное самовнимание: может смотреть только на предыдущие слова (не может видеть будущее)
  2. Кросс-внимание: чтобы обращаться к выходу Encoder

Подходящие задачи: машинный перевод, распознавание речи, суммаризация (требует "переписывания" входных данных в выходные).

Decoder-Only: серия GPT, используется для генерации

Архитектура Decoder-Only имеет только Decoder, без Encoder. Её особенности:

  • Только маскированное самовнимание (причинное внимание)
  • Авторегрессионная генерация: выводит по одному слову за раз
  • Подходит для парадигмы предобучения + тонкой настройки

Современные GPT, LLaMA, Claude, Gemini... почти все являются Decoder-Only.

Encoder-Only: серия BERT, используется для понимания

Архитектура Encoder-Only имеет только энкодер. Её особенности:

  • Двунаправленное внимание: каждое слово может видеть все слова
  • Подходит для задач понимания: классификация, распознавание именованных сущностей, извлечение ответов на вопросы
  • Предобучение с использованием "Masked Language Model" (MLM)
АрхитектураКомпонентТип вниманияРепрезентативная модельПрименимые задачи
Энкодер-декодерЭнкодер + декодерДвунаправленный + маскированный + перекрёстныйT5, BARTПеревод, суммаризация
Только декодерДекодерМаскированный (каузальный)GPT, LLaMA, ClaudeГенерация текста
Только энкодерЭнкодерДвунаправленныйBERT, RoBERTaКлассификация, NER

Почему Decoder-Only стал мейнстримом? Потому что он прост, а при крупномасштабном предобучении его способность к генерации чрезвычайно сильна. «Один размер подходит всем» на практике работает лучше, чем «специализированный дизайн».


Flash Attention: делаем вычисление внимания быстрым и экономным по памяти

Временная и пространственная сложность стандартного self-attention — O(n²); когда последовательность становится длинной (например, n=8192), это работает медленно, и памяти GPU не хватает.

Flash AttentionЭто ключевая оптимизация; её появление в 2022 году сделало обучение на длинных последовательностях выполнимым.

Узкое место памяти стандартного внимания

Стандартное self-attention должно сохранять промежуточные результаты (матрицы весов внимания) для обратного распространения. Для n=8192 это 8192×8192 ≈ 67 миллионов элементов, что само по себе занимает десятки ГБ памяти GPU.

Принципы оптимизации Flash Attention

Основная идея Flash Attention:

  1. Блочные вычисления: разбить Q/K/V на небольшие блоки и вычислять блок за блоком
  2. Online Softmax: не сохранять полную матрицу внимания; использовать математические приёмы, чтобы пересчитать её во время обратного распространения
  3. Использование SRAM: поместить блоки в высокоскоростной кэш GPU (SRAM), чтобы сократить обращения к HBM

Результат:

  • Сложность по памяти снижается с O(n²) до O(n)
  • В 2-4 раза быстрее
  • Идентичная точность (математически эквивалентно)

Почти всё обучение больших моделей сейчас использует Flash Attention или его варианты (Flash Attention v2, Memory-Efficient Attention и т. д.).


KV Cache: принципы ускорения инференса

После оптимизации обучения также требуется оптимизация инференса (генерации).KV CacheЭто ключ к ускорению инференса.

Проблема: повторяющиеся вычисления при авторегрессионной генерации

Во время авторегрессионной генерации при создании каждого нового токена приходится заново вычислять K и V всех предыдущих токенов — это очень расточительно.

Например, при генерации 100-го токена K/V первых 99 токенов уже вычислены, и их не нужно пересчитывать.

Как работает KV Cache

Идея KV Cache проста:

  • Кэшировать K и V всех предыдущих позиций
  • При генерации нового токена вычисляйте только K/V нового токена и добавляйте его в кэш
  • Используйте полный K/V для внимания

Таким образом, вычисления на каждом шаге сокращаются с O(n²) до O(n), что значительно повышает скорость генерации.

Пример

# ============================================
# Демонстрация принципа KV Cache
# ============================================

import math
import random
random.seed(42)


def softmax(x):
    max_val = max(x)
    exp_x = [math.exp(v - max_val) for v in x]
    sum_exp = sum(exp_x)
    return [v / sum_exp for v in exp_x]


def matmul(A, B):
    m = len(A)
    n = len(B)
    p = len(B[0])
    result = [[0.0 for _ in range(p)] for _ in range(m)]
    for i in range(m):
        for j in range(p):
            for k in range(n):
                result[i][j] += A[i][k] * B[k][j]
    return result


def transpose(matrix):
    return list(map(list, zip(*matrix)))


class SimpleAttentionWithKVCache:
    """Простое самовнимание с KV Cache"""

    def __init__(self, d_model: int):
        self.d_model = d_model
        self.Wq = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
                   for _ in range(d_model)]
        self.Wk = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
                   for _ in range(d_model)]
        self.Wv = [[random.normalvariate(0, 0.1) for _ in range(d_model)]
                   for _ in range(d_model)]

    def forward(self, x_new: list, kv_cache: tuple = None) -> tuple:
        """
Одношаговая генерация с KV Cache
x_new: текущий новый вход (одна позиция)
kv_cache: предыдущие K и V, (K_cache, V_cache)
Возвращает: (output, новый kv_cache)
        """

        # Вычислить Q, K, V для текущей позиции
        q = matmul([x_new], self.Wq)
        k = matmul([x_new], self.Wk)
        v = matmul([x_new], self.Wv)

        # Обновить KV Cache
        if kv_cache is None:
            K_full = k
            V_full = v
        else:
            K_cache, V_cache = kv_cache
            K_full = K_cache + k
            V_full = V_cache + v

        # Внимание: используем только Q текущей позиции
        scores = matmul(q, transpose(K_full))
        scale = math.sqrt(self.d_model)
        scores_scaled = [[v / scale for v in row] for row in scores]
        weights = [softmax(row) for row in scores_scaled]
        output = matmul(weights, V_full)

        return output[0], (K_full, V_full)


# Демонстрация процесса генерации KV-кэша
d_model = 4

# Симулируем 4 шага генерации
inputs = [
    [1.0, 0.0, 0.0, 0.0],
    [0.0, 1.0, 0.0, 0.0],
    [0.0, 0.0, 1.0, 0.0],
    [0.0, 0.0, 0.0, 1.0],
]

attn = SimpleAttentionWithKVCache(d_model)

print("=" * 50)
print("Пример демонстрации KV Cache")
print("=" * 50)

kv_cache = None
for step, x in enumerate(inputs):
    output, kv_cache = attn.forward(x, kv_cache)
    K_cache, V_cache = kv_cache
    print(f"\n"Шаг {step}:")
    print(f" Размер KV Cache: {len(K_cache)} позиций")
    print(f" Вывод: {[f'{v:.4f}' for v in output]}")

print("\n" + "=" * 50)
print("Примечание: на каждом шаге вычисляются только K/V текущей позиции,")
print("затем добавляет его в кэш, не нужно пересчитывать предыдущие!")
print("=" * 50)

KV Cache имеет небольшие затраты: требуется дополнительная память GPU для хранения K и V. Для модели 7B с контекстом 4k KV Cache занимает около 1-2GB памяти GPU — это того стоит.

Существуют также более продвинутые оптимизации:

  • Multi-Query Attention: несколько голов совместно используют K/V
  • Grouped Query Attention: Групповое совместное использование K/V

Они дополнительно сокращают использование памяти и повышают скорость инференса.


Резюме: Полный конвейер Transformer

Давайте соберём все компоненты вместе и обобщим полный поток Transformer.

Один слой Transformer только с декодером

x → LayerNorm → Masked Multi-Head Attention → 残差连接 →
    → LayerNorm → FFN → 残差连接 → 输出

Полная модель только с декодером

  1. Входные ID токенов → эмбеддинг слова
  2. Добавить позиционное кодирование
  3. Пройти через N слоёв Transformer
  4. Финальный LayerNorm
  5. Линейный слой, отображающий в размер словаря
  6. Softmax для получения вероятности следующего токена

Ключевые гиперпараметры

ГиперпараметрЗначениеGPT-2 (124M)LLaMA-7BGPT-3 (175B)
d_modelРазмерность модели768409612288
num_headsКоличество голов внимания123296
num_layersКоличество слоёв123296
d_ffПромежуточная размерность FFN30721100849152
context_lenДлина контекста10242048/40962048

Руководство по реализации кода

Если вы хотите реализовать упрощённый Transformer с нуля, шаги следующие:

  1. Реализовать нормализацию слоя
  2. Реализовать самовнимание (с маскированием)
  3. Реализовать многоголовое внимание
  4. Реализовать FFN
  5. Реализовать слой Transformer
  6. Реализовать позиционное кодирование
  7. Собрать полную модель
  8. Реализовать цикл обучения

На практике мы используем фреймворки вроде PyTorch, а не чистый Python. Реализация на чистом Python здесь нужна для демонстрации принципов и помогает понять, что делает каждый шаг.

Лучший способ понять Transformer — реализовать его самостоятельно. Даже упрощённая версия даст вам свежий взгляд на многие детали.

Другие расширения