В этой статье я собираюсь рассмотреть знаменитый генеративный предварительно обученный преобразователь из статьи Улучшение понимания языка с помощью генеративного предварительного обучения. В этой статье предлагается изучить генеративную языковую модель с использованием неразмеченных данных, а затем отрегулировать модель, предоставив примеры конкретных последующих задач, таких как классификация, анализ тональности, вывод текста и т. д. Это первая GPT из серии GPT-n, и оказалось, очень перспективный подход. Следующие версии GPT сохраняют ту же архитектуру, но увеличивают количество параметров и объем обучающих данных.
Во-первых, я рассматриваю теорию архитектуры и ее компонентов. Во-вторых, я собираюсь реализовать GPT в PyTorch с нуля и протестировать его на задаче генерации имен.
Реализация не полностью оптимизирована для обеспечения высокой производительности, поскольку основная цель кода — образовательная. Полная реализация, готовая к запуску, доступна на Google Colab и GitHub.
Теория
Обзор архитектуры
В целом архитектура GPT основана на преобразователе. Архитектура преобразователя состоит из блоков энкодера и декодера:
GPT построен из декодеров без вывода кодировщика и, следовательно, без операции внимания кодировщик-декодер:
Таким образом, декодер эквивалентен кодировщику, за исключением маскировки в блоке самоконтроля, декодеру разрешено собирать информацию только из предшествующих токенов в последовательности. Это делает модель авторегрессивной, что важно для любой языковой модели: она принимает один токен в качестве входных данных и производит оценки вероятности следующего токена в качестве выходных данных.
В целом архитектура GPT, представленная в статье Улучшение понимания языка с помощью генеративного предварительного обучения выглядит следующим образом:
Вложения
Для векторизации токенов мы используем линейное преобразование; для сохранения позиционной информации о токенах используются позиционные вложения, которые также являются результатом линейного преобразования. Обратите внимание, что в оригинальном преобразователе (статья «Внимание — это все, что вам нужно») авторы используют синусоидальное кодирование, тогда как авторы GPT используют обычное обучаемое линейное преобразование.
Замаскированное внимание к себе
Механизм внутреннего внимания — это метод, предназначенный для имитации когнитивного внимания, т. е. усиления одних частей входных данных и уменьшения других. Мотивация здесь заключается в том, что сеть должна уделять больше внимания небольшим, но важным частям данных.
Учитывая последовательность векторов токенов v_i, модель вычисляет вес w_i для каждого v_i так, что
Средневзвешенное значение
является результатом работы механизма внимания. Вес w_i получается из запросов и ключей:
Значения V являются векторами v_i.
Если запросы и ключи получены из кодировщика, это называется вниманием кодировщика-декодера. Если запросы и ключи получены из одних и тех же входных векторов v_i , это называется самостоятельным вниманием.
Для повышения производительности модели используется несколько головок механизма внутреннего внимания. Это означает, что изначально векторы Q, V, K разбиваются на n частей (где n — количество головок) и обрабатываются независимо друг от друга. После операции внутреннего внимания результаты объединяются и передаются на линейный уровень.
Подача вперед NN
Нейронная сеть с прямой связью является последней частью структуры декодера. Он состоит из двух линейных слоев, GELU и дропаута:
Первое линейное преобразование определяется с помощью (d_model, d_ff), а второе определяется с помощью (d_ff, d_model), где d_model – размер вложений токенов иd_ffявляетсяскрытым размером состояния для этого Feed Forward NN. GELU (линейная единица ошибки Гаусса) — это функция активации:
Выполнение
Мы собираемся внедрить архитектуру GPT с помощью PyTorch и протестировать задачу генерации фамилии.
Архитектура модели
Начнем с контура слоя декодера:
class DecoderLayer(nn.Module): def __init__(self, d_model: int, heads: int, dropout: float = 0.1, d_ff: int = 2048): super().__init__() self.norm_1 = Norm(d_model) self.norm_2 = Norm(d_model) self.dp = nn.Dropout(dropout) self.attn = MultiHeadAttention(heads, d_model, dropout, masked=True) self.ff = FeedForward(d_model, d_ff=d_ff) def forward(self, x): x = self.norm_1(x + self.dp(self.attn(x, x, x))) x = self.norm_2(x + self.dp(self.ff(x))) return x
Теперь давайте реализуем MultiHead Attention. Он состоит из механизма внимания и трех линейных преобразований, предназначенных для обработки входных запросов, ключей и значений; кроме того, он содержит конечный линейный слой.
class MultiHeadAttention(nn.Module): def __init__(self, heads: int, d_model: int, dropout: float = 0.1, masked: bool = False): """ params --- heads : number of heads d_model : model dimension (embeddings size) dropout : """ super().__init__() self.d_model = d_model self.d_k = d_model // heads self.h = heads self.q_linear = nn.Linear(d_model, d_model) self.v_linear = nn.Linear(d_model, d_model) self.k_linear = nn.Linear(d_model, d_model) self.attention = ScaledDotProductAttention(masked=masked) self.dropout = nn.Dropout(dropout) self.out = nn.Linear(d_model, d_model)
Как это работает?
def forward(self, q, k, v): """ params --- q : tensor [batch_size, length, emb_dim] k : tensor [batch_size, length, emb_dim] v : tensor [batch_size, length, emb_dim] returns --- output : [batch_size, length, emb_dim] """ bs = q.size(0) # perform linear operation, split into h heads and get [bs, h, length, d_k] k = self.k_linear(k).view(bs, -1, self.h, self.d_k).transpose(1, 2) q = self.q_linear(q).view(bs, -1, self.h, self.d_k).transpose(1, 2) v = self.v_linear(v).view(bs, -1, self.h, self.d_k).transpose(1, 2) scores = self.attention(q, k, v) # concatenate heads and put through final linear layer concat = scores.transpose(1, 2).contiguous().view(bs, -1, self.d_model) output = self.out(concat) return output
Требуется Q, Kи V, которые в случае GPT являются просто входными векторамиX: Q = K = V = X.После линейного преобразования нам нужно разбить векторы результатов на h частей: по одной части каждой головы. Мы можем думать об этом как об ортогональной проекции векторов в d_k = d_model // hпространства. После этого применяется маскированное внимание:
class ScaledDotProductAttention(nn.Module): def __init__(self, masked: bool = False): super().__init__() self.masked = masked if masked: self.register_buffer("mask", torch.tril(torch.ones(512, 512)) .view(1, 1, 512, 512)) def forward(self, q, k, v): """ q : tensor [batch_size, heads, length, d_model//heads] k : tensor [batch_size, heads, length, d_model//heads] v : tensor [batch_size, heads, length, d_model//heads] """ d_k = k.shape[-1] scores = (q @ k.transpose(-2, -1)) / math.sqrt(d_k) if self.masked: scores = scores.masked_fill(self.mask[:, :, :q.shape[2], :q.shape[2]] == 0, float('-inf')) scores = torch.softmax(scores, dim=-1) output = scores @ v return output
После операции внимания блок MultiHead Attention объединяет все векторы h и применяет окончательное линейное преобразование.
Слой декодера использует нормализацию слоя после каждого подуровня. Мы реализуем эту нормализацию с помощью обучаемых параметров калибровки:
class Norm(nn.Module): def __init__(self, d_model: int, eps: float = 1e-6): super().__init__() self.size = d_model # create two learnable parameters to calibrate normalisation self.weight = nn.Parameter(torch.ones(self.size)) self.bias = nn.Parameter(torch.zeros(self.size)) self.eps = eps def forward(self, x): norm = self.weight * (x - x.mean(dim=-1, keepdim=True)) \ / (x.std(dim=-1, keepdim=True) + self.eps) + self.bias return
Наконец, соберите все вместе:
class GPT(nn.Module): def __init__(self, vocab_size: int, d_model: int, n_layers: int, heads: int, d_ff: int = 2048, dropout: float = 0.1, max_length: int = 128, device: str = 'cpu'): """ params --- vocab_size : now many unique tokens d_model : tokens embeddings size n_layers : how many decoder layers heads : how many heads in attention block d_ff : embeddings size in decoder's feed forward NN dropout : probability of dropout max_length : maximum length of token sequence device : cpu or cuda """ super().__init__() self.device = device self.max_length = max_length self.embedder = nn.Embedding(vocab_size, d_model) self.pe = nn.Embedding(max_length, d_model) self.layers = nn.ModuleList([DecoderLayer(d_model, heads, dropout, d_ff) for _ in range(n_layers)]) self.ff = nn.Linear(d_model, vocab_size) self.sm = nn.Softmax(dim=1) self.to(device) def count_parameters(self): return sum(p.numel() for p in self.parameters() if p.requires_grad) def forward(self, tokens): """ tokens : [bs, length] """ pos = torch.arange(0, tokens.shape[1], dtype=torch.long).unsqueeze(0).to(self.device) x = self.embedder(tokens) + self.pe(pos) for layer in self.layers: x = layer(x) logits = self.ff(x) return logits @torch.no_grad() def generate(self, idx, idx2token, k=3, temperature=1.0): self.eval() for _ in range(self.max_length): logits = self(idx) # pluck the probs at the final step and scale by desired temperature logits = logits[:, -1, :] / temperature probs = self.sm(logits) probs, indicies = torch.topk(probs, k=k) idx_next = indicies[0][torch.multinomial(probs, 1)[0][0]] if idx2token[idx_next.item()] == '[STOP]': break idx = torch.cat((idx, idx_next[None][None]), dim=1) result = [idx2token[i.item()] for i in idx[0] if i.item() not in {0, 1}] return ''.join(result).strip()
Обучение
Мы собираемся протестировать реализацию генерации имен и фамилий (например, Тед Смит). Набор данных выглядит следующим образом:
Renee Deleon Laurie Sutton Janice Hodge Sandy Alvarado Patrice Fields Morgan Sullivan
Каждая строка представляет собой одну часть входных данных. Модель будет отслеживать каждую входную букву за буквой и предсказывать следующую букву. Подготовим данные:
with open("names.txt", 'r') as f: names = [line.lower() for line in f.read().split('\n')] # create vocabulary of tokens (letters) with stop and padding tokens vocab = ['[PAD]', '[STOP]'] + sorted(list(set(''.join(names)))) idx2token = {k:v for k,v in enumerate(vocab)} token2idx = {v:k for k,v in idx2token.items()} print(len(vocab)) # create input and target by shifting the text by one letter # e.g. "river" -> "river", "iver[PAD]" X, Y = [], [] for name in names: tokens = list(map(token2idx.get, name))[:MAX_LENGTH-1] + [token2idx['[STOP]']] while len(tokens) < MAX_LENGTH: tokens.append(token2idx['[PAD]']) x, y = tokens, tokens[1:] + [token2idx['[PAD]']] X.append(torch.tensor(x)) Y.append(torch.tensor(y))
Затем мы загружаем эти данные в даталоадер и переходим к обучающей части, которая вполне стандартна:
model = GPT(VOCAB_SIZE, EMB_DIM, N_LAYERS, N_HEADS, 512, dropout=DROPOUT, max_length=MAX_LENGTH, device=DEVICE) print(model.count_parameters()) optimizer = torch.optim.Adam(model.parameters(), lr=LR) for epoch in range(N_EPOCHS): epoch_loss_value = 0. for x, y in dataloader: x, y = x.to(DEVICE), y.to(DEVICE) probs = model(x) loss_value = F.cross_entropy(probs.view(-1, probs.size(-1)), y.view(-1), ignore_index=0) loss_value.backward() optimizer.step() optimizer.zero_grad() epoch_loss_value += loss_value.item() if epoch % LOG_EVERY_N == 0: print(epoch_loss_value/len(dataloader))
Заключение
Мы рассмотрели одну из самых популярных архитектур для языкового моделирования. Основная часть этой архитектуры — маскированный механизм самоконтроля, который 1) делает модель авторегрессивной, маскируя токены прямо к текущему 2) заставляет модель изучать наиболее важные части данных. Решая задачу генерации языка, GPT приобретает надежные «знания» языковой семантики и контекста, которые широко используются в последующих задачах, таких как классификация текста, ответы на вопросы, обобщение текста и другие.
Ссылки
- Рэдфорд, А., Нарасимхан, К., Салиманс, Т. и Суцкевер, И., 2018. Улучшение понимания языка с помощью генеративного предварительного обучения.
- Васвани А., Шазир Н., Пармар Н., Ушкорейт Дж., Джонс Л., Гомес А.Н., Кайзер Л. и Полосухин И., 2017. Внимание — это все, что вам нужно. В NIPS.