Назад к блогу

Internalizer: как контекст документа попадает в веса модели через LoRA-адаптеры

Internalizer: как контекст документа попадает в веса модели через LoRA-адаптеры

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

Большая языковая модель обычно узнаёт о документе только в момент запроса — текст приходит в контекстном окне, и с его концом знание исчезает. Альтернатива — дообучить модель под конкретный документ, но для модели на сотни миллиардов параметров это неподъёмно. Internalizer решает задачу иначе: отдельная сеть читает документ и порождает под него LoRA-адаптер, который подключается к замороженной модели. Ниже — как эта схема устроена, что именно генерируется и как адаптер применяется на инференсе.

Какую задачу решает Internalizer

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

Ключевое свойство — переносимость. Большая часть параметров Internalizer лежит в стволе; под каждую базовую модель предусмотрены лишь тонкие входной и выходной слои. Такое устройство позволяет дёшево обучать гиперсеть на малых моделях, а затем переносить её на большую. После обучения один прямой проход превращает любой документ в адаптер для такой модели.

Целевая модель при этом остаётся замороженной, потому что LoRA не изменяет базовые веса: в forward-проходе результат базовой модели дополняется вычисленным адаптером.

Что значит «portable» в названии

Переносимость опирается на то, как LoRA представляет обновление весов. Вместо явного вычисления изменения ΔW обучается его разложенное представление: большая матрица ΔW представляется двумя меньшими матрицами A и B, так что ΔW = AB. Экономия здесь количественная. При ΔW размером 10 000×20 000 (200 000 000 параметров) и ранге r=8 матрицы A (10 000×8) и B (8×20 000) дают 240 000 параметров — примерно в 830 раз меньше.

Исходные предобученные веса остаются замороженными, поэтому поверх одной базовой модели можно держать несколько лёгких и переносимых LoRA-моделей под разные задачи. Это и есть переносимость: адаптер — небольшой артефакт, который можно хранить, передавать и подключать отдельно от базовой модели.

После обучения адаптер можно слить с базовой моделью. merge_and_unload() сливает слои адаптера в базовую модель, чтобы использовать её как самостоятельную модель, и при этом не сохраняет веса адаптера в памяти. merge_adapter() тоже сливает слои LoRA в базовую модель, но сохраняет PeftModel для последующего разъединения — копия весов адаптера остаётся в памяти именно потому, что она нужна для unmerge.

Как инициализируются матрицы A и B

Матрицы A и B — это обучаемые параметры LoRA-слоя. A отображает входную размерность слоя в ранг r, B — из ранга r в выходную размерность. Их произведение задаёт обновление весов, а в прямом проходе вклад адаптера прибавляется к результату базового слоя.

По умолчанию A инициализируется случайными значениями, а B — нулями. При init_lora_weights="gaussian" A инициализируется нормальным распределением со стандартным отклонением 1/r, а B всё равно зануляется. Для эмбеддингов логика обратная: lora_embedding_A обнуляется, а lora_embedding_B инициализируется нормально.

Нулевая B на старте означает, что поведение модели не меняется: BAx = 0 ⋅ Ax = 0, поэтому y по-прежнему равно Wx. Это делает начальную фазу дообучения более стабильной.

Опция init_lora_weights=False меняет эту схему: веса A и B инициализируются случайно, поэтому адаптер перестаёт давать тождественное преобразование ещё до обучения. Такая настройка полезна для отладки и тестирования — она позволяет проверить, что адаптер действительно обучается и влияет на выход, — и не должна использоваться в других случаях: случайная инициализация нарушает стандартное поведение LoRA и может ухудшить сходимость.

Как выбираются ранг, alpha, dropout и scaling factor

Ранг r задаёт, сколько чисел хранит каждая из матриц A и B по своей «узкой» стороне. lora_alpha. Из них собирается scaling factor.

Масштаб вычисляется как alpha / r, и результат адаптера добавляется к выходу слоя:

scaling = alpha / r
weight += (lora_B @ lora_A) * scaling

То есть scaling напрямую умножает вклад адаптера перед добавлением к выходу слоя.

При включённом use_rslora=True масштаб задаётся как lora_alpha/math.sqrt(r), потому что, согласно источнику, это «was proven to work better». Иначе используется исходное значение lora_alpha/r.

По умолчанию все слои, к которым применяется LoRA, используют одинаковые r и lora_alpha из LoraConfig. Чтобы задать разные значения для отдельных слоёв, в конфиг передают rank_pattern и alpha_pattern — словари, где ключом является имя слоя, а значением ранг или альфа; ключи могут быть регулярными выражениями. Слои, не упомянутые явно, берут значения по умолчанию. Сопоставление имён выполняется регулярным выражением вида rf"(.*\.)?({pattern_key})$" по именам целевых модулей и параметров, а $ добавляется автоматически, поэтому указывать его не нужно. Если ключ не совпал ни с одним целевым модулем или параметром, выдаётся предупреждение о том, что ключ проигнорирован. Разные ранги и альфы нужны, когда требуется индивидуальная настройка ёмкости и масштаба адаптера для конкретных слоёв вместо единого значения для всех.

Как применяется адаптер на инференсе

Есть два режима. При динамическом подключении базовая модель и LoRA-адаптер загружаются и используются раздельно, и именно из-за раздельной загрузки во время инференса могут возникать задержки. Слияние с базовой моделью устраняет эту задержку: merge_and_unload() объединяет веса адаптера с базовой моделью, после чего объединённую модель можно использовать как автономную. Важно, что merge_and_unload() не является операцией на месте — результат нужно присвоить переменной.

Если нужно сохранить возможность вернуть базовую модель или загрузить другие адаптеры, применяют merge_adapter(): он сохраняет копию весов, чтобы адаптер можно было разъединить позже, а unmerge_adapter() возвращает базовую модель.

Механика слияния такова. get_delta_weight вычисляет delta_weight как transpose(weight_B @ weight_A, self.fan_in_fan_out) * self.scaling[adapter] — произведение B и A, транспонированное при необходимости и умноженное на масштаб. Затем в merge этот delta_weight прибавляется к весам базового слоя (param.data += delta_weight), а в safe_merge — к копии исходного веса с проверкой на конечность (orig_weight += delta_weight.to(orig_dtype)). После слияния forward при self.merged просто вызывает result = self.base_layer(x, *args, **kwargs), не выполняя отдельного прохода через lora_A и lora_B, поэтому A, B и delta_weight больше не нужны для вычислений. При разъединении delta_weight вычитается из весов базового слоя (param.data -= delta_weight.to(orig_dtype)).

Что с несколькими адаптерами одновременно

MultiLoRA — это подход к инференсу, при котором веса адаптеров не объединяются с весами базовой модели, а группируются отдельно на исполнении: для базовой модели выполняется обычное матричное умножение, а для адаптеров — Grouped Gemm. За счёт группировки обе операции выполняются над большими размерами матриц, что позволяет достигать хорошей утилизации. Подход позволяет батчевать запросы от разных адаптеров на общей базе модели, улучшая утилизацию GPU и компенсируя неэффективность отдельных операций с «тонкими» матрицами LoRA.

В PEFT смешивание разных LoRA-адаптеров в одном батче реализовано через аргумент adapter_names: он указывает, какой адаптер применить к каждому образцу. Сначала считается выход базового слоя, затем для каждого уникального адаптера собираются индексы соответствующих ему примеров, и к результату в этих позициях прибавляется выход LoRA. Порядок образцов в батче не важен — важно лишь согласование списка с образцами. Для базовой модели используется специальная строка "__base__".

Накладные расходы ожидаемы: размер батча эффективно сокращается до числа примеров на адаптер, и чем больше разных адаптеров в батче, тем сильнее это проявляется. Для приоритета скорости рекомендуют увеличивать размер батча и избегать большого числа разных адаптеров в одном батче, предпочитая однородные батчи. Есть и ограничения: механизм работает только для инференса, не для обучения; нельзя передавать adapter_names, если есть объединённые адаптеры (сначала нужно вызвать unmerge_adapter()); нельзя после merge_and_unload(), так как все адаптеры будут слиты в базовые веса.

Пограничные случаи и ограничения

Если адаптеры отключены, слой возвращает результат базовой модели: при self.disable_adapters выполняется result = self.base_layer(x, *args, **kwargs), а если адаптер был слит, сначала вызывается self.unmerge(). Отключение делается через disable_adapter_layers(), которая вызывает _enable_adapter_layers(enabled=False).

Если активного адаптера нет среди ключей self.lora_A, цикл по self.active_adapters пропускает его через continue, и к результату базовой модели ничего не добавляется.

Замороженность базовых весов обеспечивается _mark_only_adapters_as_trainable: для всех параметров, не входящих в adapter_param_names, устанавливается p.requires_grad = False, поэтому обучаются только параметры адаптеров. При этом enable_adapters управляет флагом requires_grad для весов адаптеров, а не для базовых весов.

Что из этого следует на практике

  • Контекст документа можно переносить в веса, не трогая базовую модель: гиперсеть порождает адаптер, а forward-проход дополняет результат базовой модели вычисленным вкладом. Замороженные веса при этом не меняются.
  • Разделение гиперсети на модельно-независимый ствол и тонкие входной/выходной слои под каждую базовую модель — то, что делает перенос с малых моделей на 284B-параметровую возможным: основная часть параметров обучается дёшево на малых моделях.
  • LoRA даёт выигрыш по числу обучаемых параметров за счёт низкорангового разложения: при ΔW 10 000×20 000 и r=8 получается примерно в 830 раз меньше параметров.
  • Нулевая инициализация B гарантирует, что до обучения адаптер не меняет поведение модели; init_lora_weights=False этот инвариант нарушает и годится только для отладки.
  • Выбор между динамическим подключением и слиянием — это выбор между гибкостью и задержкой: раздельная загрузка даёт задержки, слияние их устраняет, но merge_and_unload() не сохраняет веса адаптера, а merge_adapter() сохраняет их ради возможности разъединить.
  • Несколько адаптеров в одном батче возможны через adapter_names, но с оговорками: эффективный размер подбатча падает, механизм только для инференса и несовместим со слитыми адаптерами и с DoRA.

Как сравнивали и что получилось

В статье нет раздела о сравнении и метрике teacher-forced accuracy.

Где смотреть в коде

Источники

Похожее