Как устроен энкодер в архитектуре BERT?

Ответ

Энкодер BERT — это стек из L идентичных слоев (L=12 или 24). Каждый слой трансформер-энкодера обрабатывает последовательность векторов, обогащая их контекстуальной информацией со всех позиций.

Структура одного слоя энкодера:

  1. Multi-Head Self-Attention:
    • Входные эмбеддинги X (размерность d_model) проецируются в три матрицы: Query (Q), Key (K), Value (V).
    • Self-Attention вычисляет взвешенную сумму значений V, где веса определяются совместимостью Q и K. Формула для одной "головы": Attention(Q,K,V) = softmax(QK^T / sqrt(d_k))V.
    • Multi-Head означает, что это вычисление параллельно выполняется h раз с разными проекционными матрицами, а результаты конкатенируются и проецируются обратно в размерность d_model. Это позволяет модели фокусироваться на разных типах зависимостей.
  2. Добавление и нормализация (Add & Norm): К исходному входу слоя X прибавляется выход механизма внимания (остаточная связь), и результат нормализуется через LayerNorm.
  3. Position-wise Feed-Forward Network (FFN): Это два линейных слоя с активацией GELU между ними. Применяется независимо к каждому токену в последовательности. Формула: FFN(x) = GELU(xW1 + b1)W2 + b2.
  4. Второе Add & Norm: Выход FFN снова складывается с входом этого блока и нормализуется.

Ключевые отличия энкодера BERT от декодера в оригинальном трансформере:

  • Используется полное (не маскированное) Self-Attention, так как задача предобучения (MLM) требует доступа ко всему контексту.
  • Отсутствует механизм Encoder-Decoder Attention, так как BERT — чисто энкодерная модель.

Упрощенная реализация на PyTorch для понимания:

import torch
import torch.nn as nn
import torch.nn.functional as F

class TransformerEncoderLayer(nn.Module):
    def __init__(self, d_model=768, n_heads=12, ff_dim=3072):
        super().__init__()
        self.self_attn = nn.MultiheadAttention(d_model, n_heads, batch_first=True)
        self.linear1 = nn.Linear(d_model, ff_dim)
        self.linear2 = nn.Linear(ff_dim, d_model)
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)
        self.dropout = nn.Dropout(0.1)

    def forward(self, src):
        # 1. Multi-Head Self-Attention с остаточной связью
        attn_output, _ = self.self_attn(src, src, src)
        src = src + self.dropout(attn_output)
        src = self.norm1(src)

        # 2. Position-wise Feed-Forward Network с остаточной связью
        ff_output = self.linear2(F.gelu(self.linear1(src)))
        src = src + self.dropout(ff_output)
        src = self.norm2(src)
        return src

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

Ответ 18+ 🔞

Давай разберем, как эта штука работает, а то звучит страшно, а на деле — просто слоёный пирог из математики, только безвкусный.

Представь, что у тебя есть текст, разбитый на кусочки (токены). BERT берёт каждый кусочек, превращает в цифровой вектор (эмбеддинг) и начинает его прогонять через свою мясорубку. А мясорубка эта — просто L одинаковых слоёв, поставленных друг на друга. L — это обычно 12 или 24, то есть овердохуища вычислений. Каждый слой — это один и тот же конвейер, который немного меняет вектора, добавляя в них понимание контекста.

Из чего состоит один такой слой, этот самый «трансформер-энкодер»?

  1. Multi-Head Self-Attention (Многоголовая Само-Внимательность):

    • Берём наши вектора X и делаем из них три копии, но каждая — немного разная. Называются они Query (Вопрос), Key (Ключ) и Value (Значение). Чисто технически — умножаем на три разные матрицы.
    • Self-Attention — это хитрая жопа. Он смотрит, насколько каждый «вопрос» от одного слова похож на «ключи» от ВСЕХ слов в предложении, включая его самого. Получаются веса — кто на кого влияет. Потом этими весами взвешиваются «значения» от всех слов и суммируются. В итоге каждый вектор теперь знает про всех соседей. Формула для одной головы: Attention(Q,K,V) = softmax(QK^T / sqrt(d_k))V.
    • Multi-Head — это когда такая операция делается не один раз, а h раз параллельно, но с разными проекциями. Каждая «голова» учится смотреть на разные типы связей: одна — на соседние слова, другая — на грамматическое согласование, третья — хуй знает на что. Потом результаты всех голов склеиваются и проецируются обратно. Это как смотреть на ситуацию с разных углов, чтобы не быть, прости господи, полупидором с однобоким мнением.
  2. Добавление и нормализация (Add & Norm):

    • К исходному вектору, который пришёл на вход слоя (X), прибавляется тот вектор, который получился после внимания. Это остаточная связь — гениальная штука, которая не даёт градиентам исчезнуть и позволяет сети учиться просто «поправками» к уже имеющемуся сигналу.
    • Потом всё это дело прогоняется через LayerNorm, чтобы значения не улетали в космос и обучение было стабильным. Без этого всё накрывается медным тазом на первых же итерациях.
  3. Position-wise Feed-Forward Network (FFN):

    • Это просто маленькая полносвязная нейросеть, которая применяется КАЖДОМУ токену в последовательности ОТДЕЛЬНО (поэтому position-wise). Состоит из двух линейных слоёв и активации GELU между ними: FFN(x) = GELU(xW1 + b1)W2 + b2.
    • Её задача — нелинейно преобразовать информацию, которую собрал механизм внимания. Можно думать об этом как о «переваривании» контекста.
  4. И ещё раз Add & Norm:

    • Выход этой маленькой сети снова прибавляется к тому, что было на входе блока FFN, и снова нормализуется. Остаточные связи везде, ёпта!

Чем этот энкодер BERT отличается от декодера в классическом трансформере?

  • Внимание у него полное (не маскированное). Декодеру, который генерирует текст по слову, нельзя подсматривать в будущее. А BERT в задачах предобучения (типа Masked Language Model — «угадай пропущенное слово») должен видеть ВЕСЬ контекст, и слева, и справа. Поэтому он смотрит на все слова сразу — доверия ебать ноль к нему, он всё про тебя знает.
  • Нет блока Encoder-Decoder Attention. Потому что BERT — модель чисто энкодерная, ей не с чем декодировать, она только кодирует (обогащает эмбеддинги контекстом). Ей не нужно, в отличие от переводчика, заглядывать в исходное предложение.

Вот тебе упрощённый код на PyTorch, чтобы стало совсем понятно, что там внутри:

import torch
import torch.nn as nn
import torch.nn.functional as F

class TransformerEncoderLayer(nn.Module):
    def __init__(self, d_model=768, n_heads=12, ff_dim=3072):
        super().__init__()
        self.self_attn = nn.MultiheadAttention(d_model, n_heads, batch_first=True)
        self.linear1 = nn.Linear(d_model, ff_dim)
        self.linear2 = nn.Linear(ff_dim, d_model)
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)
        self.dropout = nn.Dropout(0.1)

    def forward(self, src):
        # 1. Multi-Head Self-Attention с остаточной связью
        attn_output, _ = self.self_attn(src, src, src)
        src = src + self.dropout(attn_output)
        src = self.norm1(src)

        # 2. Position-wise Feed-Forward Network с остаточной связью
        ff_output = self.linear2(F.gelu(self.linear1(src)))
        src = src + self.dropout(ff_output)
        src = self.norm2(src)
        return src

И вот этот слой, как кирпичик, ставится друг на друга L раз. Каждый следующий слой получает на вход обогащённые вектора от предыдущего и добавляет в них ещё немного понимания. В итоге, пройдя всю эту башню, изначальные «тупые» эмбеддинги превращаются в умные вектора, которые знают про каждое слово всё: и что оно значит, и как связано с другими. Вот и вся магия, а остальное — матричные умножения, да похуй.