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

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

Разве не удивительно, как эти модели перешли от экстрактивного обобщения (дословного копирования исходного текста) к теперь предоставлению абстрактного обобщения? Сейчас они полностью переписывают резюме, чтобы оно соответствовало стилевым предпочтениям читателя и имеющимся у него знаниям. Что еще более удивительно, так это то, что эти новые модели могут не только генерировать новый код, но и объяснять существующий код. Очаровательный.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

О неэффективности вывода мы поговорим в третьей статье. Краткий обзор: все будет хорошо — мы объединим веса модуля с произведением матриц низкого ранга.
Вернёмся к этой статье — давайте займёмся размером адаптера.

Матрицы низкого ранга как адаптеры

Давайте увеличим масштаб.

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

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

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

Большая матрица разлагается на две матрицы низкого ранга. Но сами матрицы гораздо меньше, 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.

Давайте соберем все это вместе:

Мы видим, что в качестве входных данных у нас есть singlex. Затем 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 означает, что индуктивное смещение ничего не делает, если только изменение весов не приведет к уменьшению потерь. Так что нестабильностей в начале тренировки не будет. Хороший!

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

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

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

class LoRAAdapter(nn.Module):
    def __init__(self, 
                 adaptee, # <- module to be adapted
                 r):
        super().__init__()
        
        self.r = r
        self.adaptee = adaptee
        
        # Store a pointer to the original forward implementation 
        # of the module to be adapted.
        # Then point its forward method to this adapter module.
        self.orig_forward = adaptee.forward
        adaptee.forward = self.forward
        [..]

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

        [..]
        # Adding the weight matrices directly to the adaptee,
        # which makes is more practical to report the parameters,
        # and to remove it later.
        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         768
roberta.encoder.layer.11.intermediate.dense.weight             0     2359296
roberta.encoder.layer.11.intermediate.dense.bias               0        3072
roberta.encoder.layer.11.output.dense.weight                   0     2359296
roberta.encoder.layer.11.output.dense.bias                     0         768
roberta.encoder.layer.11.output.dense.lora_A                   1       12288
roberta.encoder.layer.11.output.dense.lora_B                   1        3072
roberta.encoder.layer.11.output.LayerNorm.weight               0         768
roberta.encoder.layer.11.output.LayerNorm.bias                 0         768
classifier.dense.weight                                        1      589824
classifier.dense.bias                                          1         768
classifier.out_proj.weight                                     1        1536
classifier.out_proj.bias                                       1           2
[..]
Total parameters: 124,978,946, thereof learnable: 923,906 (0.7392%)

Подробнее читайте в Блокноте.

Взять на пробу?

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

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

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

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

full-finetuning accuracy: 0.944
lora-finetuning accuracy: 0.933

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

Но насколько хорошо? С чем мы сравниваем эти цифры? И насколько репрезентативны эти две индивидуальные тренировки? Нам просто повезло или не повезло? Цифры LoRA лучше, чем традиционный подход? Разве это не странно? Насколько хорошо мы настроили традиционный подход?

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

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

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

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

Спасибо Константину Гонсалесу, Юмиту Йолдасу, Валерио Перроне и Элине Лесик за предоставленные неоценимые отзывы во время написания этой статьи.

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

[1] Армен Агаджанян, Люк Зеттлмойер, Сонал Гупта. Внутренняя размерность объясняет эффективность точной настройки языковой модели, 2020

[2] Эдвард Дж. Ху, Йелун Шен, Филлип Уоллис, Цзэюань Аллен-Чжу, Юаньчжи Ли, Шин Ван, Лу Ван, Вэйчжу Чен. LoRA: Низкоранговая адаптация больших языковых моделей, 2021

[3] Рэнруй Чжан, Цзямин Хан, Крис Лю, Пэн Гао, Аоцзюнь Чжоу, Сянфэй Ху, Шилин Ян, Пань Лу, Хуншэн Ли, Юй Цяо. LLaMA-Адаптер: Эффективная тонкая настройка языковых моделей с нулевым вниманием, 2023

[4] Иньхан Лю, Майл Отт, Наман Гоял, Цзинфэй Ду, Мандар Джоши, Даньци Чен, Омер Леви, Майк Льюис, Люк Зеттлмойер, Веселин Стоянов. RoBERTa: надежно оптимизированный подход к предварительному обучению BERT, 2019 г.

[5] Ричард Сочер, Алекс Перелыгин, Джин Ву, Джейсон Чуанг, Кристофер Д. Мэннинг, Эндрю Нг и Кристофер Поттс. Рекурсивные глубокие модели семантической композиционности в древовидном банке настроений, 2013