Погрузитесь в адаптеры LoRA

Immerse yourself in LoRA adapters.

Исследование эффективной настройки параметров (PEFT): Интуитивное понимание настройки с использованием LoRA

Большие языковые модели (LLM) завоевали мир. За последний год мы были свидетелями массового прорыва в их возможностях, они перешли от узкоспециализированных приложений к свободному и многоходовому общению.

Невероятно, как эти модели изменились: они больше не просто копируют исходный текст, а создают абстрактные сводки, которые соответствуют стилю и знаниям читателя. Более того, эти новые модели не только создают новый код, но и объясняют существующий. Удивительно.

Часто эти большие модели настолько мощны, что даже в режиме zero-shot или few-shot демонстрируют впечатляющие результаты. Хотя это позволяет быстро экспериментировать и видеть результаты, для многих задач это обычно сопровождается настройкой модели для достижения лучшей производительности и эффективности. Однако настройка каждого из миллиардов их параметров становится неэффективной и непрактичной. Более того, учитывая размер моделей, у нас даже есть достаточно размеченных данных для обучения такой массовой модели без переобучения?

Эффективная настройка параметров (PEFT) приходит на помощь: теперь вы можете достичь отличной производительности, настраивая только небольшую часть весов. Необходимость настраивать миллиарды параметров на нескольких машинах делает процесс настройки более практичным и экономически целесообразным. Используя PEFT и квантование, большие модели с миллиардами параметров могут быть настроены на одном графическом процессоре (GPU).

Эта серия статей предназначена для опытных специалистов в области машинного обучения, которые хотят изучить PEFT и конкретно LoRA [2]:

  • В статье первой мы исследуем мотивацию для эффективной настройки параметров (PEFT). Мы рассмотрим, почему и как работает настройка, какие аспекты наших существующих практик могут быть сохранены, обобщены и применены в улучшенном виде. Мы сделаем практическую реализацию от начала до конца, чтобы создать интуитивное понимание и продемонстрировать простоту метода, который мы выбрали для исследования, LoRA.
  • Во второй статье мы углубимся в поиск хороших значений гиперпараметров, то есть рассмотрим соответствующие проектные решения при применении LoRA. По ходу работы мы установим базовые значения для сравнения производительности, а затем рассмотрим компоненты, которые мы можем изменить с помощью LoRA, их влияние и оптимальный размер.
  • На основе обученной и настроенной модели для одной задачи, в третьей статье мы теперь рассматриваем настройку нескольких задач. А что насчет развертывания? Как мы можем использовать относительно небольшой размер адаптеров, которые мы обучили для одной задачи, и реализовать механизм быстрой замены для использования одной модели для вывода результатов нескольких задач.
  • В течение первых трех статей мы получили интуитивное понимание обучения, настройки и развертывания с использованием PEFT. В четвертой статье мы станем очень практичными. Мы отойдем от учебной модели и спросим себя: “Что мы узнали до сих пор и как мы можем применить это к реальной ситуации?” Затем мы воспользуемся установленной реализацией от Hugging Face для достижения наших целей. Это будет включать использование QLoRA, который сочетает LoRA и квантование для эффективного использования памяти GPU.

Готовы погрузиться в это? Давайте начнем с объяснения, почему все это работает.

Об эффективности предварительного обучения и настройки

В своей работе Aghajanyan и др. [1] показали два интересных наблюдения о том, как изменяются слои нейронной сети во время предварительного обучения, что упрощает настройку. Это широко применимо, не только для конкретной задачи настройки.

В частности, они показывают, что предварительное обучение минимизирует внутреннюю размерность (ID) представлений. Ниже приведены две фигуры из их работы, иллюстрирующие этот эффект:

Внутренняя размерность уменьшается в процессе предварительного обучения (изображение от Aghajanyan et al.)
С увеличением емкости модели (изображение от Aghajanyan и др.) интринсическая размерность уменьшается

Вместо того чтобы настраивать все параметры, авторы обучали соответствующие модели с более малым, случайно выбранным подмножеством параметров. Количество параметров было выбрано таким образом, чтобы соответствовать 90% производительности полной настройки. Эта размерность, необходимая для достижения 90% производительности, обозначается как d90 на двух осях y на рисунке выше.

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

На второй иллюстрации мы также видим, что с увеличением емкости количество параметров, необходимых для достижения d90 в настроенной модели, также уменьшается. Как интересно. Это указывает на то, что более крупная модель может научиться лучше представлять обучающие данные – мир, который видит модель, – и создает иерархические функции, которые легко использовать в любой последующей задаче.

Одним из конкретных примеров, на который указывают авторы, является то, что для модели RoBERTa Large (354M) d90 составляет около 207 параметров. Бац! Пожалуйста, найдите этот пример на диаграмме выше, а затем также проверьте, что меньшей модели RoBERTa Base (123M) требуется больше параметров для достижения 90% производительности, здесь 896. Интересно.

Из дискуссий, которые я вел по этой теме, я узнал, что есть несколько вещей, которые стоит отметить явно:

  • Мы используем эффект ID во время настройки, но графики и числа выше относятся только к предварительному обучению. Мы просто используем числа настройки для более наглядного отображения полученного влияния на последующие результаты.
  • Использование более крупной модели приводит не только к более низкому ID относительно их размера, но и абсолютно. Мы увидим аналогичный эффект при переходе к PEFT.

В [1] вы найдете вышеуказанные иллюстрации как рисунок 2, рисунок 3, а указанные результаты взяты из таблицы 1.

В заключение, мы видим, что изученные представления во время предварительного обучения сжимают знания, которые модель усвоила, и облегчают настройку модели последующего использования с использованием более семантических представлений. Мы будем развивать эту идею с PEFT. Однако вместо случайного выбора параметров для настройки и достижения производительности на уровне 90%, мы будем использовать более направленный подход к выбору параметров для обучения и стремиться к практически полному совпадению производительности полной настройки. Увлекательно!

Что настраивать?

Мы установили, что мы можем работать с очень малым количеством параметров. Но какие? Где в модели? Мы рассмотрим это более подробно в следующей статье. Но чтобы начать размышления и сформулировать нашу проблему, давайте рассмотрим два общих подхода на данный момент:

Распределение на основе задачи: Настройка параметров, имеющих наибольший эффект, основываясь на нашем понимании задачи?

На основе задачи: При использовании настройки мы хотим сохранить знания из предварительного обучения и избежать “катастрофического забывания”. Мы понимаем, что специфические для последующей задачи знания должны происходить в головной части модели настройки и в ближайших слоях под головой (изображены зеленым), в то время как в нижних слоях и эмбеддингах мы хотим сохранить общие знания, которые мы усвоили о использовании языка (изображены красным). Часто мы направляем модель с помощью скоростей обучения для каждого слоя или даже полного замораживания нижних слоев.

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

Распределение на основе архитектурных элементов: Какие параметры лучше разместить, наиболее мощные для донастройки?

Основываясь на архитектуре: В противоположность этому, мы также можем рассмотреть компоненты нашей архитектуры, их параметры и их возможное влияние. На иллюстрации выше вы видите, например, LayerNorm и Biases, которые имеют низкую емкость, но распределены по всей модели. Они занимают центральные позиции для влияния на модель, но имеют относительно небольшое количество параметров.

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

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

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

Использование адаптеров для повышения эффективности

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

Адаптивные адаптеры и замороженные модули

Но у этого подхода есть проблема. Вы можете заметить ее? Проблема заключается в относительных размерах модуля, который нужно адаптировать, и адаптера. Если вы посмотрите на иллюстрацию ниже, вы увидите использование памяти GPU. Для эффективности мы выбираем размер нашей модели так, чтобы она максимально плотно вписывалась в доступную память GPU. Это особенно просто с архитектурой трансформера, благодаря тому, что каждый слой имеет одинаковую ширину, а даже уменьшенные проекции головок снова добавляются к полной ширине. Таким образом, мы можем выбрать размер пакета на основе одинаковой ширины компонентов трансформера.

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

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

Неэффективное использование GPU

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

Гораздо лучше

Но даже выполнение в параллель является дополнительным бременем по сравнению с полным отсутствием адаптера. Это верно для обучения и даже для вывода. Это не идеально. Кроме того, насколько большим должен быть такой адаптер?

Чего не хватает?

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

Низкоранговые матрицы как адаптеры

Давайте приблизимся.

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

Адаптируемый модуль против адаптера, полный ранг каждого

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

Произведение двух матриц низкого ранга соответствует нашим требованиям:

Адаптер разложен на две матрицы намного меньшего ранга

Большую матрицу разложили на две матрицы низкого ранга. Но сами матрицы гораздо меньше, d_in x r и r x d_out, особенно если r гораздо меньше, чем d_in и d_out. Обычно рассматриваются числа вроде 1, 2, 4, 16 для r, в то время как d_in и d_out составляют 768, 1024, 3072, 4096.

Давайте все это объединим:

Применение LoRA во время прямого прохода

Мы видим, что у нас есть один входной x. x затем умножается на исходные веса W0. W0 – это предварительно обученные веса. И x умножается на A и B, а затем оба результата складываются и формируют скорректированный выход, здесь названный x'.

Существуют разные реализации адаптеров, но в LoRA мы превращаем это в задачу оптимизации, и обе низкоранговые матрицы A и B обучаются для конкретной последующей задачи. Обучение этих меньшего числа параметров затем более эффективно, чем обучение всех параметров в W0.

Инициализация

Давайте сделаем небольшой отступ. Как бы вы инициализировали A и B? Если вы инициализируете их случайным образом, подумайте, что произойдет в начале обучения?

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

Для смягчения мы обычно используем более низкие значения скорости обучения, меньшие начальные значения или периоды разогрева, когда мы ограничиваем влияние неправильных параметров, чтобы не сильно нарушить веса. В статье по адаптеру LLAMA [3] авторы представляют нулевое гейтинг: они начинают значение ворот адаптера, которое будет умножаться на фактический вес, с 0 и увеличивают его значение в ходе обучения.

Альтернативным подходом было бы инициализировать A и B значением 0. Но тогда вы не сможете нарушить симметрию, и в процессе обучения все параметры могут рассматриваться как один параметр.

То, что на самом деле делает LoRA, довольно элегантно. Одна матрица, A, инициализируется случайным образом, в то время как другая матрица, B, инициализируется значением 0. Таким образом, произведение двух матриц равно 0, но каждый параметр все равно может быть дифференцирован индивидуально во время обратного распространения ошибки. Начало с 0 означает, что индуктивное смещение заключается в том, чтобы ничего не делать, если изменение весов приведет к снижению потерь. Таким образом, в начале обучения не будет нестабильностей. Приятно!

Initialization of LoRA adapters — Do Nothing

Как это может выглядеть в коде?

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

Давайте начнем с того, как мы настраиваем адаптер. Мы передаем ссылку на модуль, который будет адаптирован, который мы теперь называем adaptee. Мы сохраняем ссылку на его исходный метод forward и позволяем методу forward adaptee теперь указывать на реализацию метода forward адаптера.

class LoRAAdapter(nn.Module):    def __init__(self,                  adaptee, # <- модуль, который будет адаптирован                 r):        super().__init__()                self.r = r        self.adaptee = adaptee                # Сохраняем указатель на исходную реализацию метода forward         # модуля, который будет адаптирован.        # Затем указываем его методу forward на этот адаптерный модуль.        self.orig_forward = adaptee.forward        adaptee.forward = self.forward        [..]

Теперь, когда мы настроили механизм интеграции, мы также инициализируем параметры наших матриц низкого ранга. Заметьте, что мы инициализируем одну матрицу значением 0, а другую случайно:

        [..]        # Добавляем матрицы весов непосредственно к adaptee,        # что делает более практичным отчет о параметрах,        # и их удаление позже.        adaptee.lora_A = (nn.Parameter(torch.randn(adaptee.in_features, r)/                          math.sqrt(adaptee.in_features)))        adaptee.lora_B = nn.Parameter(torch.zeros(r, adaptee.out_features))

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

def forward(self, x, *args, **kwargs):  return (    self.orig_forward(x, *args, **kwargs) +    x @ self.adaptee.lora_A @ self.adaptee.lora_B  )

Эта простота выглядит элегантно в моих глазах.

Есть еще несколько интересных деталей, которые могут быть интересны, но лучше объяснять вместе с кодом. Вы найдете их в сопроводительном блокноте:

  • Как сначала заморозить всю модель
  • Как затем разморозить классификатор. Поскольку это специфично для нашей задачи и мы полностью обучаем его.
  • Как добавить адаптеры; которые все активны, не заморожены.
  • Обзор того, как размеры матрицы модуля связаны с двумя матрицами меньшего ранга A и B.
  • На сколько меньше количество параметров при использовании небольшого значения для r?

Небольшой отрывок ниже показывает, что параметры исходного модуля output.dense не обучаются (обозначены 0), но его матрицы LoRA обучаемы (обозначены 1), и, конечно же, общий классификатор модели (также обозначен как обучаемый с помощью 1):

[..]roberta.encoder.layer.11.attention.output.LayerNorm.bias       0         768roberta.encoder.layer.11.intermediate.dense.weight             0     2359296roberta.encoder.layer.11.intermediate.dense.bias               0        3072roberta.encoder.layer.11.output.dense.weight                   0     2359296roberta.encoder.layer.11.output.dense.bias                     0         768roberta.encoder.layer.11.output.dense.lora_A                   1       12288roberta.encoder.layer.11.output.dense.lora_B                   1        3072roberta.encoder.layer.11.output.LayerNorm.weight               0         768roberta.encoder.layer.11.output.LayerNorm.bias                 0         768classifier.dense.weight                                        1      589824classifier.dense.bias                                          1         768classifier.out_proj.weight                                     1        1536classifier.out_proj.bias                                       1           2[..]Всего параметров: 124 978 946, из которых обучаемых: 923 906 (0,7392%)

Для получения дополнительной информации ознакомьтесь с блокнотом.

Попробуйте?

Далее вы увидите некоторые тесты в блокноте, которые показывают, что весь процесс работает механически.

Но затем мы запускаем наш первый эксперимент и отправляем задачи обучения в SageMaker. Мы выполняем полное дообучение на исходной модели, а затем обучение с включенной LoRA, как описано здесь.

В нашем тесте мы обучаем RoBERTa Large [4] на наборе данных sst-2 [5] с использованием r=2, адаптируя параметры query и output на всех слоях. Мы используем значения 5e-5 и 4e-4 в качестве скоростей обучения для полного дообучения и дообучения с помощью LoRA.

Вот результат (подробнее в блокноте):

точность полного дообучения: 0,944точность дообучения с LoRA: 0,933

Так что это … замечательно, не так ли? Что это значит? Во-первых, это явно показывает, что весь процесс работает на механическом уровне — это замечательно. А точность более 90% показывает, что он хорошо работает.

Но насколько хорошо? С чем сравнивать эти числа? И насколько репрезентативны эти два отдельных обучающих процесса? Мы просто были удачливыми или неудачливыми? Числа LoRA лучше, чем в традиционном подходе? Это странно. Насколько хорошо мы настроили традиционный подход?

Ни один из вышеперечисленных результатов не является надежным. Мы не знаем, будут ли использование наших гиперпараметров при повторном запуске приводить к подобным результатам. Кроме того, мы использовали гиперпараметры, выбранные с полуобразованным предположением.

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

  • Установим базовые значения для сравнения
  • Найдем хорошие гиперпараметры как для базовых значений, так и для экспериментов
  • Самое главное: Углубим наше понимание метода LoRA и влияния проектных решений, согласуя наши интуиции на основе данных

До тех пор, надеюсь, вам было интересно читать эту статью.

Благодарю Константина Гонсалеса, Ümit Yoldas, Валерио Перроне и Элину Лесик за драгоценную обратную связь во время написания этой статьи.

Все изображения принадлежат автору, если не указано иное.

[1] Armen Aghajanyan, Luke Zettlemoyer, Sonal Gupta. Intrinsic Dimensionality Explains the Effectiveness of Language Model Fine-Tuning, 2020

[2] Edward J. Hu, Yelong Shen, Phillip Wallis, Zeyuan Allen-Zhu, Yuanzhi Li, Shean Wang, Lu Wang, Weizhu Chen. LoRA: Low-Rank Adaptation of Large Language Models, 2021

[3] Renrui Zhang, Jiaming Han, Chris Liu, Peng Gao, Aojun Zhou, Xiangfei Hu, Shilin Yan, Pan Lu, Hongsheng Li, Yu Qiao. LLaMA-Adapter: Efficient Fine-tuning of Language Models with Zero-init Attention, 2023

[4] Yinhan Liu, Myle Ott, Naman Goyal, Jingfei Du, Mandar Joshi, Danqi Chen, Omer Levy, Mike Lewis, Luke Zettlemoyer, Veselin Stoyanov. RoBERTa: Оптимизированный подход к обучению BERT, устойчивый к шумам, 2019

[5] Richard Socher, Alex Perelygin, Jean Wu, Jason Chuang, Christopher D. Manning, Andrew Ng и Christopher Potts. Рекурсивные глубокие модели для семантической композициональности на основе Sentiment Treebank, 2013