Каждый, кто хоть раз вводил pip install transformers, наблюдал, как терминал начинает безостановочно выводить простыню зависимостей: pytorch, accelerate, bitsandbytes, peft и многие, многие другие. Непроизвольно возникает вопрос, а это точно всё нужно? Но времени разобраться в нём обычно не хватает.
Мы в команде AIFY ежедневно работаем над созданием AI-продуктов для бизнеса. И для достижения наилучших результатов часто приходится проводить эксперименты, находить наиболее мощные инструменты для решения задач или даже дообучать модели. В какой-то момент нам стало интересно разобраться в том, что происходит внутри этих библиотек.
В этой статье мы не просто приведём список библиотек с кратким описанием каждой, а раскроем перед вами целую карту экосистемы. Благодаря ей начинающие NLP-инженеры поймут, зачем нужен каждый инструмент и как они связан с другими. А уважаемые специалисты с опытом систематизируют знания и, возможно, откроют новые возможности в знакомых библиотеках. Поэтому приступим к ревизии джентльменского набора LLM инженера!
TL;DR
PyTorch — математическим движок, позволяющий эффективно работать с тензорами, проводить CUDA-вычисления и динамически строить граф вычислений, который даёт нам возможность не задумываться о дифференцировании. Остаётся только прописывать необходимые математические преобразования (даже с if/else блоками!), а вся магия с вычислением градиентов происходит внутри.
Transformers — библиотека с архитектурами моделей, собранных из PyTorch блоков. За пару строк можно получить веса моделей и проводить эксперименты. Также присутствует класс Trainer, реализующий обучение.
Accelerate — библиотека, упрощающая распределённое обучение. Поддерживает работу в средах с несколькими GPU/TPU или даже несколькими кластерами GPU/TPU.
BitsAndBytes — отвечает за квантизацию. Добавляет PyTorch-совместимые слои с поддержкой 8bit и 4bit квантизации. Также добавляет квантованные версии PyTorch оптимизаторов.
PEFT — библиотека для дообучения transformers-моделей с помощью различных методов, позволяющих использовать минимальное количество параметров.
Unsloth — оптимизированная с помощью Triton библиотека для полного или LoRA (QLoRA) дообучения. Благодаря оптимизациям работает в 2-3 раза быстрее PEFT, а также потребляет меньше памяти.
В последнее время PyTorch установил себя как один из столпов современного машинного обучения, особенно с сфере NLP. Данный фреймворк является математическим движком, позволяющим эффективно работать с тензорами, проводить CUDA-вычисления и, самое интересное при работе с нейросетями, — динамически строить граф вычислений в процессе операций, который является основой для автоматического дифференцирования.
Что такое граф вычислений и как он строится?
Принято выделять два вида графов вычислений: статический (до версии 2.0 в TensorFlow был только такой граф вычислений) и динамический. При работе со статическим графом вычислений сначала реализуется его структура, а уже затем проводятся операции. Для наглядности рассмотрим пример, реализации простой функции:
import tensorflow as tf # В текущих версиях TF работает с динамическим графом вычислений,но для данного примера воспользуемся старой версией tf.compat.v1.disable_eager_execution() # Задаём точки входа, в которые потом будем подставлять числа x = tf.compat.v1.placeholder(tf.float32, name='x') y = tf.compat.v1.placeholder(tf.float32, name='y') # Определяем операции add_op = tf.add(x, y) sub_op = tf.subtract(y, 2.0) mul_op = tf.multiply(add_op, sub_op) # Проводим вычисления with tf.compat.v1.Session() as sess: result = sess.run(mul_op, feed_dict={x: 3.0, y: 5.0})
Плюсами статических графов является то, что их структура заранее известна, что позволяет её оптимизировать для большей производительности. К минусам можно отнести сложность отладки — не получится построчно посмотреть, как проводятся вычисления, так как они происходят внутри фреймворка. Также минусом является невозможность использовать произвольный Python код в условиях (иногда такая гибкость очень полезна, особенно в NLP).
Теперь перейдём к динамическим графам вычислений. Строятся они прямо в моменте проведения операций. Рассмотрим такой же пример:
import torch # Сразу вставляем значения x = torch.tensor(3.0, requires_grad=True) y = torch.tensor(5.0, requires_grad=True) # requires_grad - однин из важнейших аргументов, который указывает, является ли тензор переменной, по которой нужно будет вести дифференцирование # Проводим вычисления и строим их граф add_result = x + y sub_result = y - 2.0 final_result = add_result * sub_result
Как мы видим, операции над данными производятся в привычном Python коде, что добавляет гибкости (добавление условий, циклов и прочих плюшек). Такая гибкость стала одной из ключевых причин, почему экосистема LLM построена на PyTorch. К минусам можно отнести то, что после каждого вызова .backward() граф очищается и затем его нужно строить заново. Хоть данный процесс и не требует больших ресурсных затрат, но это может быть не оптимальным, ведь в процессе обучения нейронных сетей структура графа обычно не меняется.
А что с автоматическим дифференцированием?
После построения графа вычислений и вызова .backward() начинается процесс подсчёта градиентов. Давайте кратко разберём, как он работает на примере уже построенного нами графа вычислений.
Как мы видим из рисунка выше, каждый узел содержит в себе два значения: само значение узла (data) и операцию, после которой данный узел появился (grad_fn). После того, как на выходе получен результат (final_result=24), начинается процесс обратного распространения. Данный процесс заключается в вычислении градиентов (дифференцировании) для каждого узла (grad) справа-налево. Всё строится на цепном правиле дифференцировании и на локальных правилах. К локальным правилам дифференцирования относятся, например, такие:
Если , то
Если , то
И так далее
Выбор правила зависит от значения grad_fn.
Введём переобозначения:
Начало пути:
Узел :
|локальное правило|
Узел :
|локальное правило|
Узел :
|локальное правило|
Узел :
и никакой магии :)
Методы перехода от динамического графа к статическому.
Напомню, что основной проблемой динамического графа вычисления является необходимость строить его каждый раз заново. Решить данную проблему можно несколькими способами. Один из них — TorchScript. Это способ сериализации PyTorch моделей, который переводит динамический Python код в статическое высокопроизводительное представление. Модель сохраняется в файл, а при последующем запуске происходит JIT-компиляция, которая один раз создаёт граф вычислений и затем позволяет проводить вычисления без необходимости пересоздания графа.
Существует два метода создания TorchScript модели:
Трассировка. Подходит если логика статична, то есть операции не меняются и можно прогнать через граф вычислений данные, тем самым построив его:
import torch import torch.nn as nn # Те же вычисления, только обёрнуты в Torch-класс class SimpleModel(nn.Module): def __init__(self): super().__init__() def forward(self, x, y): add_result = x + y sub_result = y - 2.0 return add_result * sub_result # Экземпляр модели model = SimpleModel() # Случайные данные, которые будут применяться при трассировке x_trace = torch.rand(1) y_trace = torch.rand(1) # Процесс конвертации модели traced_script_module = torch.jit.trace(model, (x_trace, y_trace)) traced_script_module.save("traced_model.pt")
Аннотация. Применяется для моделей с динамической логикой (такое часто встречается в рекуррентных сетях или в слоях внимания). В данном случае вместо трассировки, которая может не обнаружить всей динамики, проводится полный анализ модели, а только потом происходит конвертация:
import torch import torch.nn as nn class SimpleModel(nn.Module): def __init__(self): super().__init__() def forward(self, x, y, flag): add_result = x + y # Простейший пример разветвления if flag: sub_result = y - 5.0 else: sub_result = y - 2.0 return add_result * sub_result # Экземпляр модели model = SimpleModel() # Тот же код, что и выше, только процесс конвертации выглядит так: scripted_model = torch.jit.script(model) scripted_model.save("scripted_model.pt")
Далее сохранённая модель загружается, компилируется, а затем может быть выполнена как в Python-среде, так и в С++.
Однако, TorchScript — это довольно старый и сложный метод. В версии PyTorch 2.0 появилась возможность компилировать PyTorch модель с помощью torch.compile. Технология работает в 4 этапа:
TorchDynamo (фронтенд) захватывает граф вычислений из Python кода. Если внутри встречается неподдерживаемый код (print или иные Python функции), то граф разрывается, проблемная часть выполняется на Python, после чего компиляция продолжается
AOTAutograd заранее генерирует код для обратного распространения ошибки, что позволяет оптимизировать также и его.
PrimTorch переводит PyTorch операции в небольшой набор примитивных операций для упрощения следующего этапа.
TorchInductor (бэкенд по умолчанию) преобразует захваченный граф в ядра, написанные на языке Triton (язык для GPU от OpenAI), а также объединяет операции для оптимизации вычислений.
Стоит учесть, что такой режим работы будет потреблять немного больше памяти и что первые итерации обучения будут работать медленнее из-за накладных расходов на компиляцию. Зато последующие итерации могут ускорить обучение на ~50%. Особый выигрыш заметен в больших моделях, тогда как в маленьких можно даже получить замедление из-за малого количества вычислений.
Распределённое обучение в Pytorch.
Краеугольным камнем для работы с большими моделями является распределённое обучение, которое как раз присутствует в PyTorch "из коробки". Поддерживаются такие методы распределения, как Data Parallelism, Distributed Data Parallel, Fully Sharded Data Parallel, Tensor Parallelism и Pipeline Parallelism.
Data Parallelism.
Полная копия модели загружается на каждую GPU, входной батч данных разбивается на части и каждый отправляется на свою GPU. После прямого прохода вычисляются градиенты, которые усредняются в главном процессе. Производится оптимизация, а изменения весов передаются на каждую GPU.
Distributed Data Parallel.
Аналогичен (DP) но вместо одного узла с несколькими GPU используется множество независимых процессов (по одному на каждый GPU). Градиенты усредняются между всеми процессами через коллективные операции, что устраняет узкое место в виде одного процесса.
Fully Sharded Data Parallel.
Если полностью поместить модель на GPU не получится, как в примерах выше, то можно воспользоваться FSDP. В данном методе каждая GPU хранит только уникальную часть весов модели. Например, модель имеет блоков слоёв:
. Каждый блок разбивается на
частей, где
— количество GPU:
и так далее. По итогу, на каждой GPU обычно лежит
всей модели. При прямом проходе все GPU обмениваются недостающими частями первого слоя, прогоняют свои батчи через собранный первый слой, затем из памяти очищаются все полученные части первого слоя, кроме своей и процесс повторяется для второго блока и так далее. При обратном проходе процесс повторяется для вычисления градиентов и последующей оптимизации (причём каждая GPU хранит только свою часть градиентов и состояний оптимизатора). Данный метод позволяет обучать модели в ~
раз больше по объёму, чем одна отдельная GPU, но приходится проводить частые сеансы обмена весами.
Tensor Parallelism.
При TP матричные операции внутри слоев модели разделяются между несколькими GPU. Вместо разбиения целых блоков, как в FSDP, TP разделяет сами тензоры (матрицы весов). Например, линейный слой с матрицей весов размера
x
может быть разделен по столбцам на
частей, где
— количество GPU. Каждый GPU получает свою часть матрицы размера
x. При прямом проходе все GPU получают одинаковый вход
, каждая GPU выполняет умножение на свою часть весов
, после чего GPU обмениваются результатами для формирования полного выхода (обычно с помощью простой конкатенации) для перехода к матрице
. Аналогично, проводится обратный проход.
Pipeline Parallelism.
Это метод распределения обучения, при котором модель разделяется на последовательные блоки слоев. Эти блоки распределяются наGPU. Входной микробатч поступает на GPU с первыми блоками, обрабатывается, а затем результат переходит на GPU со следующими блоками. В это время на первую GPU поступает уже следующий батч, образуя конвейер.Таким образом, в стабильном состоянии все GPU одновременно выполняют вычисления над разными микробатчами.
Таким образом, PyTorch — это фундамент, который предоставляет нам гибкость динамического графа вычислений, автоматическое дифференцирование, возможности оптимизации вычислений и масштабируемость. Все остальные библиотеки, про которые мы будем говорить ниже, будут кирпичиками поверх PyTorch.
Данная библиотека строится над PyTorch. Операции из PyTorh собираются в логические блоки или даже полноценные модели. Также есть возможность скачать веса любой модели, находящейся на HuggingFace или загрузить свою собственную.
Ради интереса можно открыть корневой init.py библиотеки и увидеть 10k строк кода с огромным количеством различных моделей, переходя по которым можно увидеть, как они реализованы.
Вот, например, реализация блока внимания для Qwen3 MoE.""" Код немного исправлен для удобочитаемости. Оригинал можно найти в transformers\models\qwen3_moe\modeling_qwen3_moe.py """ class Qwen3MoeAttention(nn.Module): """Multi-headed attention from 'Attention Is All You Need' paper""" def __init__(self, config: Qwen3MoeConfig, layer_idx: int): super().__init__() self.config = config # Класс, в котором прописаны все характеристики Qwen3MoE # Тут было объявление переменных # Проекции для запроса, ключа и значения self.q_proj = nn.Linear( config.hidden_size, config.num_attention_heads * self.head_dim, bias=config.attention_bias ) self.k_proj = nn.Linear( config.hidden_size, config.num_key_value_heads * self.head_dim, bias=config.attention_bias ) self.v_proj = nn.Linear( config.hidden_size, config.num_key_value_heads * self.head_dim, bias=config.attention_bias ) self.o_proj = nn.Linear( config.num_attention_heads * self.head_dim, config.hidden_size, bias=config.attention_bias ) # Блоки нормализации self.q_norm = Qwen3MoeRMSNorm(self.head_dim, eps=config.rms_norm_eps) self.k_norm = Qwen3MoeRMSNorm(self.head_dim, eps=config.rms_norm_eps) self.sliding_window = config.sliding_window def forward( self, hidden_states: torch.Tensor, position_embeddings: Tuple[torch.Tensor, torch.Tensor], attention_mask: Optional[torch.Tensor], ... ): # Линейные проекции с нормализацией query_states = self.q_norm(self.q_proj(hidden_states).view(hidden_shape)) key_states = self.k_norm(self.k_proj(hidden_states).view(hidden_shape)) value_states = self.v_proj(hidden_states).view(hidden_shape) # Позиционные эмбеддинги (RoPE) cos, sin = position_embeddings query_states, key_states = apply_rotary_pos_emb(query_states, key_states, cos, sin) # Механизм внимания # Транспонирование key_t = key.transpose(-2, -1) attn_weights = torch.matmul(query, key_t) * self.scaling # Маска внимания if attention_mask is not None: attn_weights = attn_weights + attention_mask attn_weights = F.softmax(attn_weights, dim=-1) attn_output = torch.matmul(attn_weights, value) # Финальная проекция return self.o_proj(attn_output)
В коде transformers довольно просто залипнуть. Это ценная кладезь знаний и реализаций LLM (и не только) на PyTorch. Если хочется залезть внутрь современных моделей и понять, как они работают, то transformers — незаменимый помощник для вас.
Такая модульность и читаемость кода представляет из себя только одну сторону медали. Другой, не менее важной являются инструменты для эффективной работы с этими моделями. И здесь на сцену выходит класс Trainer, целью которого является предоставление единого интерфейса для управления обучением и тестирования моделей с большим набором удобных инструментов и оптимизаций. Причём можно обучать даже свои модели, которые наследуются от torch.nn.Module и имеют схожий с transformers интерфейс.
Если залезть в код класса Trainer, который, к слову, читается довольно несложно, то можно увидеть стандартный цикл обучения:
for epoch in range(epochs_trained, num_train_epochs): batch_samples = ... # Получаем наборы батчей # Переменная для аккумуляции градиента tr_loss = torch.tensor(0.0, device=args.device) for i, inputs in enumerate(batch_samples): # Определяем, нужно ли проводить аккумуляцию градиента do_sync_step = (step + 1) % args.gradient_accumulation_steps == 0 or (step + 1) == steps_in_epoch # Вычисляем loss + делаем .backward() tr_loss_step = self.training_step(model, inputs, num_items_in_batch) tr_loss = tr_loss + tr_loss_step if do_sync_step: # gradient clipping self.accelerator.clip_grad_norm_(model.parameters(), args.max_grad_norm) self.optimizer.step() self.lr_scheduler.step() learning_rate = self._get_learning_rate() model.zero_grad() # Далее логирование + оценка (если необходимо)
Интересный факт: до ноября 2024 года в логике аккумуляции градиентов библиотеки Trainer была небольшая ошибка, которая мало того, что нарушала эквивалентность обучению с полным батчем, так ещё и негативно влияла на качество дообучения. Поэтому есть смысл обновить библиотеку до более свежей версии. Подробнее я писал про эту проблему тут.
Таким образом, Transformers хранит в себе тысячи реализованных моделей, веса которых можно получить с помощью пары строк, и выступает в роли центрального хаба, вокруг которого построены все остальные библиотеки, описываемые ниже.
Подробнее разобраться в функционале библиотеки можно в документации HF. Она довольно хорошо структурирована и её несложно читать.
Представьте, что вы отладили процесс обучения модели на одной видеокарте, но теперь нужно запустить его на GPU-кластере. Для этого придётся изучать torch.distributed , разбираться, как передавать данные и многое другое, что требует очень много времени. Данная библиотека решает эту проблему. Она позволяет проводить распределённое обучение без необходимости переписывать код для каждой среды заново. Это означает, что можно запускать один и тот же PyTorch код на любых конфигурациях среды обучения: от одного GPU, до кластеров TPU/GPU. И чтобы это всё работало нужно добавить несколько строк кода.
from accelerate import Accelerator accelerator = Accelerator( mixed_precision="fp16", # Включаем mixed precision gradient_accumulation_steps=gradient_accumulation_steps, # Аккумуляция градиента log_with="tensorboard", # Трекер project_dir="./logs", # Директория для логов ) model, optimizer, dataloader, scheduler = ... # Определяем как обычно # Оборачиваем в accelerate model, optimizer, dataloader, scheduler = accelerator.prepare( model, optimizer, dataloader, scheduler ) ... # Обычный процесс обучения # Единственное отличие — используется обёртка torch.backward() из accelerare accelerator.backward(loss)
Перед началом работы, вызывается команда accelerate config, которая анализирует конфигурацию обучающей среды (один компьютер с множеством GPU/TPU или даже несколько компьютеров с GPU/TPU и прочее) и создаёт файл с настройками.
compute_environment: LOCAL_MACHINE # Среда обучения. Может быть: KUBERNETES, GOOGLE_COMPUTE_ENGINE, AMAZON_SAGEMAKER и прочие. distributed_type: FSDP # Стратегия параллелиза. Может быть: NO, MULTI_GPU (DP, DDP), FSDP, DEEPSPEED (Мощнейший фреймворк от Microsoft для распределённого обучения) fsdp_config: # Конфигурация FSDP fsdp_auto_wrap_policy: TRANSFORMER_BASED_WRAP fsdp_backward_prefetch_policy: BACKWARD_PRE fsdp_state_dict_type: FULL_STATE_DICT fsdp_sharding_strategy: FULL_SHARD num_machines: 10 # Допустим, у нас 10 компьютеров с двумя GPU на каждой машине в локальной сети machine_rank: 0 # Нужно менять на каждой машине main_process_ip: 192.168.1.10 # Все остальные машины будут стучаться сюда main_process_port: 29500 num_processes: 20 # Сколько всего GPU в кластере (Допускаем, что в каждой машине 2 GPU) main_training_function: main mixed_precision: bf16 # Тип данных, в которых производятся вычисления. Также может быть fp16 use_cpu: false dynamo_backend: INDUCTOR # См. Примечание 3.
Примечания:
Про DP, DDP, FSDP было описано в разделе выше.
Если вы хотите запустить распределённое обучение на 10 машинах, то придётся либо руками запускать accelerate-процесс на каждой машине, либо настроить SLURM, который сделает это автоматически. Благо, accelerate умеет работать внутри SLURM.
Данный параметр как раз включает режим компиляции графа вычислений PyTorch, про который мы говорили выше.
После определения конфигурационного файла, остаётся только запустить обучение командой accelerate launch main.py. В случае, если конфигурация среды обучения меняется, то нужно просто либо поменять файл с настройками, либо запустить обучение с новыми аргументами, не меняя код:
accelerate launch \ --num_machines 18 \ main.py
Одной из больных тем распределённого обучения является выход из строя одной или нескольких машин/GPU. Стандартным сценарием поведения в такой ситуации является падение всего обучения. Чтобы минимизировать временные затраты на перезапуск обучения, введён механизм сохранения состояний. Каждые шагов нужно просто вызывать
accelerator.save_state(), а в случае ошибки можно просто откатиться на последний чекпоинт с помощью accelerator.load_state().
В итоге, Accelerate абстрагирует нас от деталей реализации DDP, FSDP или DeepSpeed, позволяя писать чистый код обучения, который будет работать одинаково и на ноутбуке с CUDA, и на промышленном GPU\TPU кластере. С помощью этой библиотеки каждый может получить доступ к мощным инструментам для распределённого обучения.
Для более глубокого погружения также советую изучить документацию HF.
Библиотека разработана для квантования моделей. Квантование подразумевает под собой уменьшение точности чисел (например, вместо 32-битных чисел переходим на 8-битные числа) для уменьшения веса модели и увеличения скорости работы. Грубо говоря, если модель имеет 7B параметров, то ей необходимо как минимум ~28 ГБ только под веса (32 бита на параметр = 4 байта). Если же использовать 8-битные числа, то потребуется уже ~7 ГБ. Разница ощутима, но платить за неё приходится потенциальной деградацией качества модели. Bitsandbytes помогает минимизировать проблемы квантования. Чтобы понять, как всё устроено, предлагаю разобраться в основных модулях библиотеки.
LLM.int8()
Одной из сложностей в процессе квантования является обработка выбросов (веса, значения которых на порядки отличаются от остальных). Чтобы минимизировать деградацию LLM, используется метод LLM.int8(). Принцип его работы следующий:
Находим выбросы в нормированных весах (). Обычно, под выбросом понимают числа:
(то есть значение веса имеет отклонение от среднего
).
Веса делятся на 2 типа: без выбросов и с выбросами. Для весов с выбросами дополнительно сохраняется коэффициент масштабирования. После чего все веса переводятся в 8-битное представление.
При последующем использовании весов часть без выбросов просто перемножается на входные данные. А часть с выбросами сначала переводится в FP16 (тут используется коэффициент масштабирования для перевода) и только потом производится перемножение.
Так как выбросы появляются не так часто, то такие накладные манипуляции можно считать незначительными, но они способны минимизировать ущерб модели при снижении точности.
8-битные оптимизаторы
Как мы помним, почти каждый оптимизатор хранит в дорогостоящей памяти свои переменные. Например, Adam для каждого параметра хранит два момента (первого и второго порядков), что увеличивает требования к памяти в ~2 раза. И логичным решением данной проблемы является квантование состояний оптимизатора. Для этого в библиотеке реализованы обёртки над PyTorch оптимизаторами: bnb.optim.Adam8bit, bnb.optim.SGD8bit и другие.
QLoRA (дообучение с 4-битным квантованием)
Метод QLoRA соединил в себе базовое LoRA обучение и агрессивное 4-битное квантование в формат NF4. Основные веса модели квантуются в NF4, а к ним добавляются LoRA адаптеры, которые участвуют в дообучении.
К слову, формат NF4 принимает всего 16 значений из отрезка , но большинство точек сосредоточено у 0, что чаще всего и необходимо для весов.
Таким образом, Bitsandbytes важнейший инструмент в демократизации LLM. Мы получаем возможность экспериментировать с моделями, которые могут требовать дорогостоящих видеокарт в изначальном состоянии.
Изучить, как работают форматы чисел, можно в этой статье с рисунками, примерами кода и подробным объяснением. Если интересно разобраться, как квантование влияет на модель, то очень советую эту статью.
Выше мы разбирали класс Trainer, основная цель которого — автоматизация цикла обучения модели. Однако, чаще всего приходится дообучать модели под конкретные задачи. Менять все веса — нецелесообразно, поэтому базовым решением является LoRA. Суть данного метода заключается в добавлении адаптеров к имеющейся архитектуре модели. И с помощью PEFT этого можно добиться за несколько строк кода.
Процесс дообучения выглядит так:
Берём Transformers-модель.
Оборачиваем её с помощью PEFT, добавляя адаптеры и конфигурацию дообучения. Также замораживаем веса основной модели (просто с помощью PyTorch аргумента requires_grad=False)
Если углубиться в работу LoRA, то весь процесс довольно прост:
Transformers модель оборачивается с помощью кода:model = get_peft_model(base_model, lora_config)
Внутри данного метода мы проходим по каждому слою модели и, если слой является целевым (то есть тот, на который мы навешиваем LoRA веса), меняем nn.Linear на lora.Linear. Данный класс и содержит в себе матрицы и
, которые инициализируются так:
self.lora_A[adapter_name] = nn.Linear(self.in_features, r, bias=False) self.lora_B[adapter_name] = nn.Linear(r, self.out_features, bias=lora_bias)
При прямом проходе просто делаем следующее:
# Проводим x через замороженный слой модели result = self.base_layer(x, *args, **kwargs) # Получаем всё необходимое для LoRA слоя lora_A = self.lora_A[active_adapter] lora_B = self.lora_B[active_adapter] dropout = self.lora_dropout[active_adapter] scaling = self.scaling[active_adapter] # Производим вычисление result = result + lora_B(lora_A(dropout(x))) * scaling
Обратный проход представляет из себя обычный .backward()
Используем Trainer для дообучения модели, который, в свою очередь, использует Accelerate для распараллеливания (если возможно).
Предобученные адаптеры сохраняются и могут быть использованы вместе с Transformers-моделью.
В библиотеке принято выделять 4 основных метода дообучения: с помощью адаптеров (Adapters), с помощью изменения промпта (Soft Prompt), с помощью векторов масштабирования (IA3) и с помощью вращения изначальных весов (OFT\BOFT). Разберём суть каждого метода.
Adapters
Наиболее известный метод, который заключается в добавлении к весам матрицы адаптера, представляющий из себя набор дополнительных обучаемых весов (~0.1-1% от изначального количества весов). Эти веса ставятся параллельно слоям матрицы (можно выбирать, для каких именно слоёв будут добавляться веса: q_proj, k_proj, v_proj и другие). принцип работы подробнее описан здесь.
Soft Prompt
Этот класс методов работает не с весами внутри модели, а с входными данными. Вместо того чтобы вручную подбирать текстовый промпт ("Переведи это: ..."), мы обучаем токены, которые будут добавляться к входным. Эти токены не соответствуют конкретным словам из словаря модели (псевдо-токены). Например, на вход поступает последовательность токенов , которая затем проходит через эмбеддер:
и уже после этого этапа добавляются обучаемые токены:
.
К плюсам можно отнести то, что обучаемых параметров очень мало (~0.01%). Данный метод хорошо работает на больших моделях, но на маленьких может уступать другим методам.
IA3
Данный метод считается более экономичным (~0.01-0.1% весов дообучается), чем LoRA и в ряде few-shot задач может даже превосходить его. Суть IA3 в том, что выходы слоёв поэлементно умножаются на вектора (происходит масштабирование). Эти вектора подавляют ненужные признаки и усиливают нужные, что помогает модели более правильно решать поставленную задачу.
В коде это выглядит так (немного упрощено):
# Класс IA3Layer. Путь: peft\tuners\ia3\layer.py # Инициализация вектора weight = torch.randn(weights_size) self.ia3_l[adapter_name] = nn.Parameter(weight) # Внутри метода forward # Проход через слой модели result = self.base_layer(x, *args, **kwargs) result *= self.ia3_l[adapter_name]
OFT\BOFT
Основным недостатком методов, меняющих веса элементов, является возможное деградирование модели (забывание фактов или увеличение вероятности галлюцинаций). Но что если не менять веса модели, а повернуть их в пространстве так, чтобы модель стала лучше решать задачу, но при этом как можно меньше деградировала? Именно это и реализует OFT. Матрицы весов модели умножаются на матрицы , где
(то еть матрицы ортогональны), производя вращение в пространстве.
В коде это выглядит так (немного упрощено):
# Класс OFTLayer. Путь: peft\tuners\oft\layer.py # Создаём ортогональную матрицу self.oft_R[adapter_name] = OFTRotationModule( r if not block_share else 1, # Число независимых блоков n_elements, # Число параметров для одного блока oft_block_size, # Размер блока self.in_features, # Размерность входа ... ) # Внутри класса OFTRotationModule веса задаются так self.weight = nn.Parameter(torch.empty(r, n_elements)) # r — количество независимых блоков в блочно-диагональной матрице # Далее из этих весов создают ортогональную матрицу. В ход идёт несложная алгебра, в которую углубляться не будем. # Применяем ортогональное преобразование к x oft_R = self.oft_R[active_adapter] x = oft_R(x) # Затем применяем базовый слой result = self.base_layer(x, *args, **kwargs)
Бывают случаи, когда для одной модели может использоваться несколько адаптеров. Поэтому в библиотеке есть механизм по объединению нескольких адаптеров в один единый, что позволяет получить модель с комбинированными способностями без дополнительного дообучения. Причём этот механизм немного интереснее простого усреднения.
Существует 2 метода объединения весов:
TIES (Trim, Elect Sign Merge)
Для каждого параметра вычисляется его важность на основе его изменения относительно базовой модели. Сохраняются только наиболее значимые изменения (топ ), остальные обнуляются.
Для каждого параметра определяется доминирующее направление изменения ( или
).
Усредняются только те значения параметров, знаки которых совпадают с доминирующим направлением.
DARE (Drop And REscale)
Веса в адаптерах случайным образом сбрасываются к исходным значениям базовой модели с заданной вероятностью (например, 30%).
Оставшиеся веса масштабируются, после чего усредняются.
Данный метод проще и часто используется, как подготовительный этап перед TIES-объединением.
В итоге, PEFT — это базовая библиотека, содержащая в себе десятки методов дообучения моделей. Это позволяет инженеру не привязываться к одной технике, а выбирать оптимальную для задачи: максимальную экономию (Soft Prompt), баланс качества и скорости (LoRA) или сохранение знаний (OFT). А такие возможности, как объединение адаптеров (DARE/TIES) и вовсе открывают путь к созданию полноценных конструкторов из моделей.
По традиции, советую HF документацию к библиотеке, в которой всё подробно объяснено. Также можно изучить влияние LoRA гиперпараметров на результат дообучения моделей в статье от Thinking Machines.
В случаях, если вы уверены, что вам нужно именно LoRA (QLoRA) дообучение или даже полное дообучение, то стоит обратить внимание на данную библиотеку. В отличие от PEFT, наиболее важные части PyTorch (линейные слои, механизм внимания, шаг обратного распространения и прочие) переписаны на Triton, про который мы уже говорили. Это уменьшает создание временных тензоров, оптимизирует работу с памятью и даёт до 2-кратного ускорения обучения с экономией памяти до 70%. В результате, вы можете проводить эксперименты быстрее и использовать модели большего размера на том же железе.
Однако, других методов дообучения в Unsloth пока что нет, но библиотека активно развивается, так что ещё всё впереди. Ради интереса вам точно стоит почитать их документацию, в которой можно найти ответы на все вопросы: гайды по дообучению, десятки примеров дообучения конкретных моделей, а также гайды по подбору гиперпараметров. К слову, мы уже разбирали подбор гиперпараметров для LoRA дообучения в данной статье.
Итак, ревизия джентльменского набора LLM-инженера завершена. Мы прошли путь от слепого копирования строк из requirements.txt до чёткого понимания роли каждого инструмента в механизме современной LLM-экосистемы.
Нами был изучен путь, как PyTorch блоки собираются в архитектуру модели внутри Transformers, как происходит сжатие весов для запуска огромных моделей с помощью Bitsandbytes и как максимально эффективно проводить дообучение моделей с помощью PEFT или Unsloth, под капотом которых скрыта магия Trainer и Accelerate.
Целью этой статьи был не просто пересказ документации, а формирование незаменимых знаний которые помогут вам расти дальше. Мы надеемся, что теперь эта экосистема будет для вас не просто безликим списком зависимостей в requirements.txt, а понятным набором инструментов, с помощью которого вы сможете более оптимально решать свои задачи.
Успехов вам в экспериментах! И пусть эти знания будут прочным фундаментом для ваших проектов. Искренне ваши Notes On ML и команда AIFY :)
Источник


