Шаг на пути к пониманию Трансформеров

Выпуск GPT-3 стал переломным моментом в истории машинного обучения. Вполне вероятно, что на нашу работу как специалистов по данным сильно повлияют GPT и их потомки. Чтобы понять, как работают трансформеры и, следовательно, как их лучше всего использовать, важно сначала понять, как функционируют их предшественники.

Этот проект будет основан на этой⁽¹⁾ фантастической статье 2003 года Бенжио и др., в которой продемонстрировано, что распределенные представления символов могут быть объединены с прогнозами вероятности нейронной сети. Полученные модели показали себя намного лучше, чем модели n-грамм при статистическом моделировании. Мы будем внимательно следить за процессом, использованным при построении makemore от Andrej Karpathy. Мы будем использовать простой набор данных, состоящий из 32 000 имен, отобранных из US SSA.

Наша цель — создать MLP, способный генерировать уникальные, осмысленные имена, которых нет в наборе данных. Мы будем сильно опираться на PyTorch, так как он обладает фантастическими возможностями для обработки тензорных операций. Мы также будем использовать Matplotlib и NumPy. Вот пример того, что у нас будет к концу.

Обзор процесса

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

Язык моделирования сложен отчасти из-за проклятия размерности. В английском ~150 000 слов. Моделирование совместного распределения вероятностей последовательности из 5 слов дало бы 150 000⁵-1 = 7,6×10²⁵ отдельных параметров, что является чрезмерно большим количеством. Вместо этого мы можем использовать альтернативный подход и заметить, что некоторые слова имеют внутреннее сходство друг с другом.

Например, предложения Кошка гуляла в спальне и В комнате бегала собака очень похожи просто потому, что слова в них похожи. , то есть "собака" и "кошка", "прогулка"и"бег", "то"и а и т. д. Следовательно, вероятностное распределение, построенное на этом предложении, должно иметь эти слова как равновероятные. В этом главный прорыв статьи Бенджио 2003 года. ⁽¹⁾

Применяя эту концепцию к нашему генератору имен, мы можем

  • связать каждый символ в нашем алфавите с вектором признаков, используя n-мерный вектор
  • выразить совместную функцию вероятности последовательностей символов через векторы признаков этих символов в последовательности
  • одновременно изучать векторы признаков и параметры функции вероятности

В статье Bengio et al. очень подробно рассказывает о том, как это работает, и я настоятельно рекомендую вам прочитать его! Это очень удобоваримо и информативно.

Создание набора данных

Я не буду освещать некоторые из более простых аспектов этого, однако, если вы хотите продолжить, вы можете найти полный файл Jupyter Notebook здесь.

После запуска нашего импорта и загрузки набора данных мы можем создать наш словарь символов. Мы будем использовать « в качестве токена для пробелов до и после слов. Мы создадим два словаря, один для преобразования из String в int, а другой — для обратного преобразования.

Теперь, когда у нас есть словарь, мы можем создать наш набор данных. Для этого мы создадим функцию build_dataset. Эта функция принимает в качестве входных данных наш список слов и выводит два тензора: один для контекста, который NN принимает в качестве входных данных, а другой — для маркировки этого контекста. Обратите внимание на переменную block_size — это один из наших гиперпараметров. Мы коснемся этого позже.

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

Построение модели

Теперь мы должны создать экземпляр нашей нейронной сети, которая будет состоять из трех слоев. Первый слой, C, будет тензором формы (27, 2), который будет действовать как справочная таблица. C будет иметь 27 строк, по одной на каждый символ нашего словаря, и 2 столбца. Этот вектор называется нашим встраиванием, и каждое из них будет представлять собой двумерный вектор, обозначающий положение этого символа на плоскости. По мере того, как модель узнает, величина и направление каждого из этих векторов будут меняться, и модель в конечном итоге группирует символы, которые она считает «похожими», вместе.

Вторым слоем будет «скрытый» слой W1 формы (6, 300). Мы создадим его с 300 узлами, при этом каждый узел получит в качестве входных данных 2 значения из нашего тензора контекста (C) для каждого из 3 символов из нашего блока контекста, всего 6 значений. Число 300 было выбрано полуслучайно. Это один из наших гиперпараметров, который можно настроить позже для оптимизации производительности модели.

Третий и последний слой будет нашим выходным слоем. Он будет иметь форму (300, 27). Ему требуется 300 строк для приема входных данных от каждого из наших узлов скрытого слоя и 27 столбцов для вывода логарифмической вероятности для каждого из наших 27 символов.

Каждый слой будет создан с использованием torch.randn(). Это функция PyTorch для возврата тензора, заполненного случайными значениями из стандартного нормального распределения. Во время обучения эти значения будут итеративно корректироваться, чтобы уменьшить функцию потерь (также известную как функция стоимости). Этот процесс известен как обучение.

Мы можем создать эту NN, используя следующий код.

Обучение модели — обзор

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

Этот процесс обратного прохождения модели известен как обратное распространение. Обратное распространение использует концепцию, известную как градиентный спуск. Я не буду здесь подробно описывать, как это работает, но это важная концепция для понимания. По сути, мы начинаем с выхода нашей модели и вычисляем градиент нашего выхода по отношению к слою перед ним. Затем мы делаем шаг на один слой глубже и снова вычисляем градиент. Этот процесс в значительной степени опирается на цепное правило, что делает вычисление градиента на каждом шаге очень эффективным. PyTorch упрощает этот процесс, сохраняя необходимую информацию внутри и позволяя нам вызывать метод .backward() позже. Вот почему после создания NN для всех параметров мы устанавливаем requires_grad = True.

Значения градиента в каждом узле в NN говорят нам, насколько сильно этот конкретный узел влияет на выходные данные нашей модели. Изменяя все значения наших узлов с пропорциональной скоростью в направлении «более правильного», мы можем продвигать нашу модель в направлении получения лучших прогнозов.

Обучение модели — подробности

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

Чтобы ускорить обучение, мы будем выбирать подмножества наших общих данных обучения на каждой итерации. Мы выбрали batch_size = 64, однако его можно настроить, и, вероятно, здесь может быть более оптимальное значение для оптимизации времени обучения. Не стесняйтесь играть с ним!

Проход вперед

Теперь, когда у нас есть наши мини-пакеты, мы можем использовать нашу контекстную матрицу для извлечения соответствующих вложений для каждой записи в нашем мини-пакете. Индексация PyTorch чрезвычайно эффективна. В результате мы можем использовать наш тензор индексов, чтобы напрямую извлекать точки данных из нашего набора данных. Затем мы можем использовать эти точки данных для индексации нашего тензора контекста. Процесс выглядит примерно так, за исключением того, что мы можем заменить 1 на batch_size.

После извлечения соответствующих вложений мы должны изменить их форму, чтобы выполнить матричное умножение между ними и нашим скрытым слоем. Как и в большинстве случаев в PyTorch, есть несколько способов добиться этого. Наиболее эффективным способом является использование метода .view(). PyTorch сохраняет каждый тензор в памяти в виде списка значений, и с помощью .view() мы можем просто изменить форму тензора, не изменяя его в памяти. Использование таких методов, как .reshape(), требует большего объема памяти. Чтобы упростить модификацию нашего кода, мы можем передать аргументы -1 и n_embed * block_size. Таким образом, мы можем изменить количество контекста или встраивания без необходимости изменять аргументы в .view(). PyTorch достаточно умен, чтобы увидеть -1, а затем использовать необходимое количество строк в зависимости от количества заданных столбцов.

Как только наши вложения примут правильную форму, мы можем взять точечный продукт вложений и весов нашего скрытого слоя (W1), а затем добавить смещения (b1). Затем мы передаем результат этих операций через функцию активации, в данном случае tanh.

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

Следующим шагом будет получение результирующих значений нашего скрытого слоя h и передача их в наш выходной слой. Подобно первому шагу в нашем прямом проходе, мы берем скалярное произведение нашего скрытого слоя (h) и весов (W2) нашего выходного слоя и добавляем смещения выходного слоя (b2). Выходные данные модели можно интерпретировать как вероятности следующего символа с учетом входного контекста. Чтобы действительно интерпретировать выходные данные как вероятности, их необходимо сначала нормализовать, чтобы сумма была равна 1.

К счастью, PyTorch упрощает нам эту задачу. Используя torch.nn.functional.cross_entropy(), мы можем нормализовать и рассчитать потери за один шаг, завершая наш прямой проход. Весь процесс занимает всего четыре строки кода.

Обратный проход

Теперь у нас есть прогноз из нашей модели и функция потерь, показывающая, насколько неточным был наш прогноз. Мы можем работать в обратном направлении через нашу модель, чтобы подтолкнуть наши параметры в правильном направлении, используя градиентный спуск, как упоминалось ранее. К счастью, PyTorch делает этот процесс очень простым для нас. Он отслеживает все операции, выполняемые с нашими тензорами, и позволяет нам выполнять их обратное распространение, просто вызывая loss.backward(). Этот метод обновит градиенты для всех параметров, которые повлияли на значение loss. В нашем примере градиенты этих параметров уже установлены на ноль, но на практике нам нужно будет сбрасывать их на ноль при каждом проходе через модель. Этот шаг выглядит так.

Обновление нашей модели

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

Собираем все вместе

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

Оценка потерь

После того, как мы обучили нашу модель, мы должны проверить, насколько она хороша на самом деле. Для этого мы можем запустить наши обучающие и тестовые данные через функцию потерь. Умный способ сделать это — создать функцию, способную вычислять потери для любого из наших разделений данных, которая представляет собой функцию split_loss, которую вы видите выше. Эта функция, по сути, является прямым проходом нашего обучающего шага, однако она использует декоратор PyTorch, @torch.no_grad(). Этот декоратор сообщает PyTorch, что мы не собираемся вычислять градиент для какого-либо кода в следующем блоке кода. Передача ‘train’, ‘val’ или ‘test’ выведет потерю модели для желаемого разделения.

Выборка из модели

Геометрическая интерпретация вложений

Два измерения

Три измерения