Введение
Всем привет, Хабр! В своей первой статье хотел бы рассказать о своем селф‑эдьюкейтинге в сфере машинного обучения, а именно о нескольких подходах к LoRA дообучению NLP модели. Все началось с рассуждения о том, что при полном LoRA файн‑тюниге адаптеры применяются к линейным слоям всех трансформер‑блоков модели, но при этом во время обработки промпта и генерации ответа активность каждого слоя разная. Исходя из данного факта возникают логичные вопросы: Различается ли тематика промпта по линейным слоям? Есть ли разница при дообучении модели на определенную тематику путем применении адаптеров к более активным слоям на этой тематике между дефолтным полным LoRA файн‑тюнингом? Насколько сильно модель просядет в общих вопросах между этими двумя методами дообучения (и просядет ли вообще)? Чтобы ответить для себя на данные вопросы я решил провести небольшое исследование. В качестве «подопытного» была выбрана модель Qwen3-1.7B, скачанная из репозитория на Hugging Face. Все обучение и тесты проходили на RTX 3050 Ti Laptop (4 ГБ видеопамяти), 16 ГБ RAM, с использованием актуальных библиотек: torch 2.5.11 + cu121, transformers 5.14.1, peft 0.20.0, bitsandbytes 0.50.0.
Существующие исследования
Перед тем как перейти к личному опыту в данном вопросе, хотелось бы поделиться уже существующей статьей Act‑LoRA (Dawadikar et al., Information 2026, 17, 283), в которой исследуется та же самая гипотеза. В ней авторы предлагают новый подход Act‑LoRA в котором перед началом обучения вычисляется «важность» каждого слоя на основе его L2 — нормы активации и берется топ‑К наиболее активных слоев для последующего применения к ним адаптеров:
Шаг 1: Средняя L2 — норма активации по одному батчу (для батча и слоя
):
Шаг 2: Накопление по пробным батчам (при начальном условии
):
Шаг 3: Усреднение по пробному датасету:
Шаг 4: min‑max нормировка в [0, 1]:
Шаг 5: Отбор топ‑К слоев:
Обозначения:
-
— Множество слоев‑кандидатов
-
— индекс слоя,
-
— число пробных мини‑батчей
-
— мини‑батч, сэмплированный из датасета целевой задачи
-
— Множество всех пробных батчей, где
-
— Множество активаций слоя
на батче
-
— Вектор активации
‑го токена
-
— Размер батча в токенах (длина последовательности).
-
— Усредненная оценка активности слоя
-
— Нормированная важность, где
-
— Отобранное подмножество слоев под адаптеры.
Выводы авторов:
В разделе 7.1 авторы говорят о том, что адаптация топ‑к сохраняет большую часть качества при снижении стоимости обучения и инференса, та же здесь авторы дают оговорку, что эффект сильно зависит от размера датасета и деградация заметнее на малоресурсных задачах GLUE. Вывод раздела: метод пригоден для средне и высокоресурсных наборов, где данных хватает компенсировать сниженную емкость. Основные цифры из таблицы 5 статьи Act‑LoRA:
|
Модель |
k |
|
Экономия GPUh |
Сокращение параметров |
|
DeBERTaV3-Base |
6 |
-1.1% |
+21.7% |
49.7% |
|
DeBERTaV3-Base |
2 |
-3.6% |
+40.0% |
82.9% |
|
LLaMA-3.1–8B |
24 |
-0.13% |
+2.1% |
24.9% |
|
LLaMA-3.1–8B |
16 |
-3.2% |
+7.7% |
49.9% |
Со своей стороны хочу отметить, что просадка в 1.1% получена при вдвое меньшем числе параметров адаптера (150К против 300К), следовательно мы не можем заявить, что это результат именно выбора слоев.
В разделе 7.3 авторы говорят о том, что активации отражают структуру, сформированную предобучением, а градиенты зависят от ладшафта лосса и динамики оптимизации, так же авторы вводят оговорку, что номы активации могут дрейфовать после начала обучения, поэтому метрика используется статически, до старта. Авторы в таблице 9 в своей статье приводят сравнение устойчивости ранжирования на 10 сидах:
|
Модель |
Метрика |
Kendall — |
|
DeBERTaV3-Base |
activation norms |
|
|
DeBERTaV3-Base |
gradient norms |
|
|
LLaMA-3.1–8B |
activation norms |
|
|
LLaMA-3.1–8B |
gradient norms |
|
В разделе 7.4 авторы отмечают, что важность слоев определяется архитектурой модели, а не семантикой задачи, и подают это как достоинство, говоря о том, что метрику не нужно пересчитывать под каждый датасет. Данный вывод практически сразу дает ответ на мой вопрос о зависимости активации слоев от тематики задачи. Так же в разделе 8, пункте 2 авторы пишут о вычислительных ограничениях, которые не позволили им проверить модели выше 8B. Собственно в моем случае те же самые ограничения не позволили мне выбрать модель с бОльшим числом параметров.
Сводка различий
По итогу во время прочтения статьи Act‑LoRA я для себя выявил следующие пробелы в методологии авторов, которые я учел в своем исследовании:
-
1) Отсутствие случайного контроля. В статье Act‑LoRA нет сравнения с LoRA на случайно выбранных k слоях, без такого контроля нельзя заранее сказать, что информативный выбор превосходит случайный. В моем случае будет итоговое сравнение с применением адаптеров к случайному набору k слоев.
-
2) Параметры не уравнены. В Act‑LoRA сравнивается конфигурация с 150К и 300К обучаемых параметров. Поэтому просадка качества может объясняться меньшим размером адаптера, а не выбором слоев. В моем случае будет пересчитан ранг так, чтобы бюджет совпал.
Так же в своем исследовании я ввел метод выбора слоев по градиенту лосса. Если сравнивать его с активацией, то активация — это свойство прямого прохода, оно определяется весами и структурой стека (что подтверждено в статье Act‑LoRA), а градиент — это свойство расхождения предсказания с целью, то есть он напрямую зависит от данных.
Где— норма Фробениуса, а не L2 вектора.
Смысл данной величины в том, чтобы понять, насколько лосс «требует» подвинуть веса этого слоя: большой градиент означает, что модель плохо справляется с данными именно здесь.
Мое ожидание было про тематическую локализацию — что у каждого домена (темы) свой набор значимых слоев. Активации для этого не годились, они не знают какая перед моделью задача. Поэтому в теории градиенты выглядят естественным кандидатом на чувствительный к данным сигнал.
Хочется отметить еще то, что в разделе 7.3 статьи Act‑LoRA наблюдение показывает, что градиентные нормы менее стабильны между сидами (значение Kendall — в таблице 9). Авторы объясняют это как шум оптимизации, я же предполагаю, что градиенты реагируют на данные. Так же авторы не проводили измерения разброса между задачами, а только между сидами, поэтому вопрос: «Различают ли градиенты домены?» остается открытым.
В моем случае я измерял два типа согласованности ранжирований:
-
Между сидами внутри домена (шум)
-
Между доменами при фиксированном сиде (сигнал)
Доменный эффект признается существующим, только если сигнал превышает шум. Так же корпуса подобратны так, чтобы разделить тему и форму: медицина и физика взяты из одного жанра, а код добавлен как контраст по форме. Без этого любой сигнал можно было бы объяснить различием жанров, а не предметных областей.
Этап 1: Проверка на различие метриками темы
Прежде чем начать обучать что‑либо нужно проверить исходную гипотезу о том, что могут ли метрики различать домены? Основная ставка была сделана на градиентные нормы. Ключевое требование дизайна — разделить тему и форму текста. Основная пара корпусов была подобрана из одного жанра: научные абстракты сопоставимой длиный и регистра.
|
Корпус |
Источник |
Объем |
Медиана симв. |
Роль |
|
Medicine |
500 |
1078 |
Тема А |
|
|
Physics |
arXiv, только Physics Archive |
500 |
975 |
Тема В |
|
Code_python |
500 |
1454 |
Контраст по теме |
|
|
Control |
500 |
811 |
Нейтраль для метрики дельта |
Шаг 1: Подготовка данных
Чтобы жанр реально совпадал, тексты пришлось почистить. В PubMed часто встречаются структурные заголовки вроде BACKGROUND: и METHODS:, в arXiv их нет, и это уже различие формы. Так же в arXiv попадается моно формул. Отфильтруем сырые данные, чтобы получилось 4 набора по 500 текстов: медицина, физика, код и нейтральная Википедия:
import re# Диапазон длянMIN_CHARS, MAX_CHARS = 500, 3000# Структурные заголовкиSTRUCT_HEAD = re.compile( r"\b(BACKGROUND|OBJECTIVE|METHODS?|RESULTS?|CONCLUSIONS?|PURPOSE|AIM|" r"INTRODUCTION|DISCUSSION|FINDINGS)\s*:", re.IGNORECASE)# Служебные пометки старых записей PubMedPUBMED_NOISE = re.compile(r"\(author's transl\)|\[In \w+\]|\bAbstract not available\b", re.I)# Доля символов в математической разметке (сделано для arXiv, т.к. в нем доля может быть большая, а это уже различие формы, а не темы).def latex_ratio(text: str) -> float: math_chars = len(re.findall(r"\$[^$]{1,200}\$", text)) backslash = text.count("\\") return (math_chars * 10 + backslash) / max(len(text), 1) # Метод очисткиdef clean_ok(text: str) -> bool: if not (500 <= len(text) <= 3000): # одинаковый диапазон длин return False if STRUCT_HEAD.search(text): # выкидываем "BACKGROUND:", "METHODS:" return False if latex_ratio(text) > 0.02: # выкидываем формульные абстракты return False if text.count(".") < 4: # должна быть связная проза return False non_ascii = len(re.findall(r"[^\x00-\x7F]", text)) if non_ascii > len(text) * 0.03: # Отсев не-латиницы (в PubMed попадаются транслитерации) return False return True
Шаг 2: Замер средней L2 — нормы активации слоев на каждом домене
Для итогового сравнения в своем эксперименте я так же решил измерить L2 — нормы активации, как это было описано в статье Act‑LoRA. Опираясь на выводы авторов, можно предположить, что скорее всего результат замера не даст искомого сигнала. Для замера данной величины я использую хук на блоке внимания каждого слоя:
import randomimport numpy as npimport torchdef probe_actnorm(tokenizer, model, texts, n_layers, batches, seed) -> np.ndarray: rng = random.Random(seed) sample = rng.sample(texts, min(batches, len(texts))) sums = np.zeros(n_layers) hooks, buf = [], {} # Фабрика хуков с замыканием def make_hook(idx): def hook(_mod, _inp, out): h = out[0] if isinstance(out, tuple) else out buf[idx] = h.detach().float().norm(dim=-1).mean().item() return hook # Хук на самовнимание: снимается выход до residual-сложения и нормализации for i, layer in enumerate(model.model.layers): hooks.append(layer.self_attn.register_forward_hook(make_hook(i))) try: # Прогоняем в режиме инференса из-за запрета на построение графа и запрета на запись в version counter with torch.inference_mode(): for text in sample: enc = tokenizer(text, return_tensors="pt", truncation=True, max_length=MAX_LEN).to(model.device) buf.clear() model(**enc) # Заполнение буфера for i in range(n_layers): sums[i] += buf.get(i, 0.0) finally: # Очищаем хуки перед каждым прогоном т.к. они находятся в модели, а не в функции for h in hooks: h.remove() return sums / len(sample)
Далее прогоняем по 100 текстов на каждом домене и усредняем. Получаем 28 чисел, по одному на слой. Для промежуточной оценки возьмем из них топ 10 наибольших для каждого домена:
medicine: [18, 19, 20, 21, 22, 23, 24, 25, 26, 27] physics: [18, 19, 20, 21, 22, 23, 24, 25, 26, 27]code_python: [18, 19, 20, 21, 22, 23, 24, 25, 26, 27]
Вывод 2 шага: Получился одинаковый топ 10 набор слоев для каждого домена. Из этого можно сказать, что данная метрика вообще не различает темы, что и подтверждается авторами статьи Act‑LoRA.
Шаг 3: Замер градиентных норм
У активаций есть очевидная слабость: они показывают, что происходит внутри модели, но ничего не знают о задаче. Модель просто читает текст и ничего не выдает по причине отсутствия задачи. Поэтому логичным решением будет добавить вторую метрику: попрошу модель предсказать текст, к форвард‑прогону добавлю бэквард и на этом этапе замерю градиент по лоссу, чтобы понять, насколько сильно каждый слой хочет измениться, чтобы ошибаться меньше. Логика простая: большое значение = модель плохо справляется именно здесь. В отличие от активации, модель на разных доменах может ошибаться по‑разному. Расчет здесь как раз на то, что если существует тематическая локализация, то градиенты должны ее показать.
import randomimport numpy as npimport torchdef probe_gradnorm(tokenizer, model, texts, n_layers, batches, seed) -> np.ndarray: rng = random.Random(seed) sample = rng.sample(texts, min(batches, len(texts))) sums = np.zeros(n_layers) # Те же матрицы, куда Act-LoRA ставит адаптеры targets = [[l.self_attn.q_proj.weight, l.self_attn.v_proj.weight] for l in model.model.layers] for p in model.parameters(): p.requires_grad_(False) for pair in targets: for p in pair: p.requires_grad_(True) try: for text in sample: enc = tokenizer(text, return_tensors="pt", truncation=True, max_length=MAX_LEN).to(model.device) # Промпта нет, текст оценивается целиком: нужен отклик слоев на распределение домена out = model(**enc, labels=enc["input_ids"]) model.zero_grad(set_to_none=True) out.loss.backward() for i, pair in enumerate(targets): g = 0.0 for p in pair: if p.grad is not None: # norm() по всей матрице - скаляр на слой. Как и в probe_actnorm, нейронный уровень теряется g += p.grad.detach().float().norm().item() sums[i] += g finally: model.zero_grad(set_to_none=True) for p in model.parameters(): # Восстанавливаем для дальнейшего использования p.requires_grad_(False) return sums / len(sample)
Как и при замере нормы активации прогоняем по 100 текстов каждого домена и усредняем. В результате так же получаем ранжирование 28 слоев, и для промежуточной оценки возьмем из них топ 10:
medicine: [0, 9, 11, 13, 14, 15, 16, 17, 18, 20] physics: [0, 9, 11, 13, 14, 15, 16, 17, 18, 20]code_python: [9, 11, 12, 13, 14, 15, 16, 17, 18, 20]
Вывод шага 3: В результате получаем 3 практически одинаковых набора топ 10 слоев, с единственным различием в 2 слоя на домене с кодом. Предварительно можно это объяснить шумом и нестабильностью градиентов (о чем как раз и заявляют авторы статьи Act‑LoRA и отмечают это как баг). Но для 100% утверждения того, что на этой метрики сигнал тоже отсутствует, необходимо убедиться в том, что это действительно шум.
Шаг 4: Статистический аппарат
Следующим шагом необходимо понять насколько списки из, полученных на предыдущих двух шагах, наборов из 28 чисел похожи. Нам важны не абсолютные значения, а порядок: какой слой активнее какого. Для этого рассчитаем два показателя для каждой метрики: корреляцию Кейндалла и меру Жаккара по топ 10.
Где — число согласованных / несогласованных пар,
— количество слоев.
Где и
— множества из топ 10 слоев двух разных доменов.
Корреляция Кендалла сравнивает весь порядок, то есть рассчитывается важность между всеми парами 28 слоев (378 пар). В моем случае результат будет показывать насколько коррелируют между собой слои каждой метрики на каждой паре доменов.
Мера Жаккара показывает насколько похожи наборы слоев на различных доменах каждой метрики (в моем случае, расчет индекса идет не для всего набора из 28 слоев, а лишь для топ 10). Именно данный показатель скажет насколько сильно различаются слои на доменах (и различаются ли вообще).
На каждой метрики были рассчитаны оба показателя для следующих пар доменов: медицина — физика (отличаются только темой), медицина — код и физика — код (отличаются темой и формой):
|
Пара доменов |
|
|
|
|
|
medicine |
+ 0.981 |
1.000 |
+ 0.832 |
0.879 |
|
medicine |
+ 0.968 |
1.000 |
+ 0.811 |
0.768 |
|
physics |
+ 0.963 |
1.000 |
+ 0.792 |
0.828 |
Для более достоверного результата дополнительно были проделаны шаги 2 и 3 еще на двух сидах.
Далее необходимо отделить сигнал от шума, так как только по одному показателю совпадения между медициной и физикой (0.832) мы не можем сказать много это или мало, возможно, что данный показатель между сидами окажется таким же. Для этого я измерил два типа совпадений (между сидами одного домена, но с разной выборкой текстов и между разными доменами), которые как раз и дадут полную картину результатов первого этапа:
|
Метрика |
Шум |
Сигнал |
Разрыв |
|
|
0.995 |
0.971 |
+ 0.025 |
|
|
0.965 |
0.812 |
+ 0.153 |
|
|
0.825 |
0.595 |
+0.229 |
Суть дельты в данном случае в том, чтобы нивелировать архитектурную составляющую, так как профиль actnorm почти целиком определяется устройством модели, а доменная составляющая размывается в фоне. Идея в том, чтобы измерить фон на нейтральном тексте и вычесть его. Общая часть сокращается и остается отличие домена от нейтрали. Поэтому было принято решение замерить actnorm важность слоев на нейтральном корпусе Википедии и поочередно вычесть ее из actnorm важности слоев каждого домена, к каждому результату вычитания применить min‑max нормировку, получив, тем самым, набор delta важности слоев для каждого домена. После чего повторение расчетов корреляции Кендалла и меры Жаккара для метрики delta с последующим отделением шума от сигнала.
Вывод 1 этапа: На метрике actnorm вполне ожидаемый минимальный разрыв расхождения между доменами и внутридоменного шума, что в очередной раз подтверждает то, что L2 — норма активации не различает тематики. На метрике gradnorm разрыв заметно больше, но для более точного ответа сделано подробное разложение сравнения профилей важности по градиентным нормам (шум внутри домена и различие между доменами):
|
Область |
Шум |
Сигнал |
Разрыв |
|
Все 28 слоев |
0.965 |
0.812 |
+ 0.153 |
|
Внутри топ 10 |
0.960 |
0.654 |
+ 0.306 |
|
Остальные 18 |
0.942 |
0.695 |
+ 0.247 |
Исходя из всех проведенных измерений, можно сказать, что gradnorm выделяет устойчивое ядро слоев (9, 11, 14–18, 20) одинаковое для всех доменов: показывает, что 8 из 10 слоев присутствуют во всех прогонах на всех доменах. Однако порядок внутри топ 10 набора меняется сильно, как видно из последней таблицы:
падает с 0.960 до 0.654. Так же хочется сказать, что 0.960 — это шум внутри домена по тому же порядку, а при переходе к другому домену ранжирование заметно расходится. Будь это пограничным шумом, то просело бы и внутридоменное значение. Из этого всего следует, что доменный сигнал существует, но живет он в относительных весах внутри набора слоев, а не в каких‑то отдельных слоях.
Этап 2: Сравнение стратегий выбора слоев
Поскольку на первом этапе выявить тематическую локализацию слоев не удалось, но при этом градиентные нормы выделяют слои из середины, в отличии от L2 норм, которые выделяют 10 последних слоев, вопрос переформулирован: отличается ли выбор слоев между этими двумя метриками и отличается ли выбор слоев по этим двум метрикам от случайного при равном бюджете? Во втором этапе я дообучаю дефолтую модель на пяти конфигурациях, где каждая конфигурация имеет 3 сида, за исключением random, которая имеет 5 сидов. 5 сидов в random объясняются тем, что у нас на каждом сиде меняются не только стартовые значения весов адаптеров после инициализации, но и набор 10 слоев для них. После дообучения замеряется Perplexity каждого сида каждой конфигурации и считается его разброс.
|
Плечо |
Принцип выбора |
Роль |
|
|
все 28 слоев |
Стандартная LoRA, точка сравнения |
|
|
10 слоев с наибольшими нормами активации |
Воспроизведение метода из Act‑LoRA |
|
|
10 слоев с наибольшими нормами градиентов |
Альтернативный сигнал важности |
|
|
10 случайных слоев |
Критический контроль |
|
|
10 слоев с наименьшими нормами активации |
Обратный контроль |
Число обучаемых параметров LoRA линейно по рангу и числу адаптируемых слоев. Чтобы конфигурация на k слоях имела тот же бюджет, что и полная, ранг пересчитывается. Без данной поправки сравнение измеряло бы размер адаптера, а не стратегию выбора слоев:
В качестве датасета для обучения был выбран датасет: CyberNative/Code_Vulnerability_Security_DPO, содержащий 4656 исходных строк на 11 языках. При первичной обработки были выявлены и удалены ~500 дублирующихся строк, все строки отфильтрованы по длине символов с условием: promt + completion 2400 символов, так же при составлении train и eval следим за тем, чтобы выборки были сопоставимы по составу языков. По итогу в датасете осталось 4136 пригодных строк.
import jsonimport randomfrom pathlib import PathHERE = Path(__file__).parentSRC = HERE / "secure_programming_dpo.json"OUT = HERE / "data"SPLIT_SEED = 20260809 # Фиксирован: разбиение должно быть одинаковым для всех плеч и сидов# Убирает markdown-оберткуdef strip_fence(text: str) -> str: t = text.strip() m = re.match(r"^```[a-zA-Z#+]*\s*\n(.*?)\n?```$", t, flags=re.DOTALL) return m.group(1).strip() if m else t# Задает формат датасету: инструкция-ответdef build_example(row: dict) -> dict: return { "lang": row["lang"], "prompt": row["question"].strip(), "completion": strip_fence(row["chosen"]), }# Читаем все сторки из файлаrows = [json.loads(ln) for ln in SRC.read_text(encoding="utf-8").splitlines() if ln.strip()]# Проверяем строки на дубли, оставляем только уникальныеseen, uniq = set(), []for r in rows: key = r["question"].strip() if key not in seen: seen.add(key) uniq.append(r)examples = [build_example(r) for r in uniq]# Фильтруем по длине символовfitted = [e for e in examples if len(e["prompt"]) + len(e["completion"]) <= 2400]# Разделяем по языку, чтобы train и eval были сопоставимы по составуrng = random.Random(SPLIT_SEED)by_lang: dict[str, list] = {}for e in fitted: by_lang.setdefault(e["lang"], []).append(e)for lst in by_lang.values(): rng.shuffle(lst)# Разделяем на train и eval langs = sorted(by_lang)per_lang_eval = max(1, 300 // len(langs))eval_set, pool = [], []for lang in langs: lst = by_lang[lang] eval_set.extend(lst[:per_lang_eval]) pool.extend(lst[per_lang_eval:])rng.shuffle(pool)train_set = pool[:3000]# Проверяем, что выборки не пересекаютсяtrain_keys = {e["prompt"] for e in train_set}eval_keys = {e["prompt"] for e in eval_set}overlap = train_keys & eval_keysprint(f"Пересечений train/eval: {len(overlap)}") # Должно быть 0# Сохраняем выборки в файлOUT.mkdir(exist_ok=True)for name, data in (("train", train_set), ("target_eval", eval_set)): path = OUT / f"{name}.jsonl" with path.open("w", encoding="utf-8") as f: for e in data: f.write(json.dumps(e, ensure_ascii=False) + "\n")
По итогу подготовки обучающих данных получилась train выборка размером в 3000 строк и eval выборка размером 297 строк (по 11 примеров каждого из 11 языков).
Следующим действием поочередно запускается каждый из 17 прогонов дообучения. Хочу сделать оговорку, что перед дообучением модели пришлось сквантизировать модель до NF4, чтобы была память под адаптеры, так как. VRAM хватало впритык для инференса дефолтной модели BF16 + контекстное окно. Данный мув считаю возможным, потому что все 17 дообучений и последующие бенчмарки проводились на квантованных до 4 бит моделях, что одинаково для всех режет качество и не влияет на результат объекта исследования (что, собственно, и делали с большими моделями авторы статьи Act‑LoRA). Обучение проходило при следующих условиях:
|
Параметр |
Значение |
|
Модель |
Qwen3-1.7B, d = 2048, 28 слоев |
|
Квантизация базы |
4-bit NF4, double quant |
|
Целевые модули |
q_proj, v_proj |
|
Данные |
3000 примеров, безопасный код (11 языков) |
|
Эпох / шагов |
2 / 750 |
|
Learning rate |
2*10^{-4}, cosine, warmup 3% |
|
lora_alpha |
2r |
|
max_length |
512 (P90 ~320 токенов) |
Гиперпараметры идентичны для всех плеч, различается только множество адаптируемых слоев и компенсирующий ранг:
|
Плечо |
Слои |
Разброс std индексов |
|
|
0 — 27 |
8.08 |
|
|
s0: 2, 3, 5, 11, 12, 13, 14, 21, 24, 27 s1: 1, 2, 5, 6, 12, 13, 17, 24, 25, 27 s2: 4, 7, 8, 13, 15, 16, 18, 19, 22, 23 s3: 7, 11, 12, 14, 15, 17, 21, 22, 23, 26 s4: 3, 6, 13, 16, 17, 19, 21, 22, 23, 24 |
8.20 |
|
|
18–27 |
3.20 |
|
|
9, 11, 12, 13, 14, 15, 16, 17, 18, 20 |
2.87 |
|
|
0–9 |
2.87 |
При средней скорости прогона на моем железе в ~4 сек. / шаг, один прогон занимал от полутора до двух часов. После всех 17 прогонов и замеров на каждом Perplexity, получается следующий результат:
Perplexity меряет приспособление к целевому распределению, а не решение задачи. Поэтому рейтинг плеч корректно читать так: одни стратегии лучше подгоняют модель под распределение обучающих данных, чем другие. Переносить это на «качество ответов» нельзя.
Где N — число оцениваемых токенов, а — вероятность, которую модель приписывает токену
при данном префиксе.
Для М примеров — взвешивание по числу токенов:
где
Вывод по 2 этапу: Если смотреть на первую измеренную метрику (Perplexity), то первое, что нужно отметить, это что все различия на порядок превышают разброс между сидами (0.0013 — 0.0051), это означает, что усредненное значение каждого плеча несет в себе довольно достоверную информацию. Так же можно заметить, что самый крупный эффект дает не выбор слоев, а сам факт дообучения: 4.62 у дефолтной модели и ~1.50 у любого плеча. Настолько резкое улучшение perplexity можно объяснить тем, что датасет дообучения узкий: имеет конкретный формат ответов, характерные конструкции, единый стиль оформления кода, поэтому модель довольно хорошо усвоила синтаксис. Так же можно отследить зависимость разброса адаптеров по глубине от итогового перплексити, а именно: чем шире разнесены адаптеры, тем ниже перплексити. Разброс объясняет не всё: topk_actnorm и bottomk имеют одинаковый разброс 2.87 (оба непрерывные блоки по 10 слоев), но различаются на 4.7%. Разница в том, где блок стоит: средний слой 22.5 против 4.5. Ранние слои хуже подходят для адаптации — внутри плеча random корреляция числа слоев с индексом < 10 и perplexity равна +0.822. Механизм того, что почему ранние слои плохи точно не установлен, но правдоподобное объяснение такое: ранние слои работают с токенным и синтаксическим уровнем, который у кода и естественного языка во многом общий и уже хорошо настроен предобучением. Задача дообучения — сдвинуть композицию и стиль, а это уровень средних и поздних слоев. Основной вывод 2 этапа заключается в том, что случайный выбор слоев не уступает выбору по метрикам важности при равном бюджете параметров. Разделение полное: худший прогон random (1.5101) лучше лучшего прогона topk_gradnorm (1.5221). Метрика при этом небесполезна, она отличает плохие слои от хороших: bottomk заметно хуже topk_actnorm, разрыв 4.7% при шуме 0.0016. Но лучшие слои она не находит, концентрация адаптеров на «важных» слоях проигрывает их распределению по глубине.
Этап 3: Общий бенчмарк
Для итогового бенчмарка измеряется Perplexity на нейтральном корпусе (wikitext-103, 200 токенов) и HellaSwag (300 вопросов). Другой ключевой метрикой является точность предпочтения. Исходный датасет содержал пары: «безопасный код» (chosen) и «уязвимый» (rejected) на одинаковых промптах. Обучение использовало только chosen, варианты rejected подель не видела. Обучение было обычным SFT на парах: инструкция — chosen, контраста в обучающем сигнале не было, модель не получала указания, что безопасность — значимое измерение, а не синтаксис, стиль или выбор библиотек. Суть данной метрики в том, чтобы проверить: не выучила ли модель задачу? А если сказать более конкретно: формирует ли SFT на положительных примерах предпочтение к этим примерам над альтернативой?
Выборка составила 1149 пар за счет примеров, не вошедших ни в обучение ни в целевой eval. Случайный уровень — 0.500.
Сводные результаты:
Вывод по 3 этапу: При замере Perplexity на нейтральном тесте ожидалось то, что модель, дообученная на коде, должна просесть на нейтральном тексте, но по итогу улучшение явно видно на всех плечах относительно базы. Есть три причины, объясняющие данный результат:
-
Компенсация квантизации. Обучаемая база в 4 бита, в которой адаптеры частично восстанавливают потерянную точность. Любое обучение возвращает часть точности, и плечи, размещающие адаптеры удачнее, возвращают ее больше.
-
LoRA консервативна по устройству. Базовые веса заморожены, а обновление низкоранговое, которое затрагивает только Q и V проекции. Разрушить общие способности таким инструментом трудно.
-
Адаптация оказалась не доменной. Если бы адаптеры усваивали специфику кода, то был бы виден размен: лучше на коде, но хуже на Википедии. Но наблюдается идеальная положительная корреляция. Значит выучивается что‑то общее, а не предметное.
HellaSwag плечи не различает: все значения выше базового, но между собой практически неразличимы, поэтому разброс в 0.003 — 0.013 можно отнести к шуму и сказать, что на 300 вопросах метрика оказалась слишком грубой.
По результатам измерения метрики предпочтения можно сказать, что ни одно плечо не достигло случайного уровня, и задача не усвоена. Тут хочется еще дать пояснение тому, почему база изначально ниже случайного: уязвимые версии проще и идеоматичнее, а безопасные содержат дополнительные проверки, валидацию, нестандартные конструкции. Для модели это менее вероятный текст. Нормировка по длине снимает эффект объёма, но не эффект «естественности», сформированный предобучением. Те же самые факторы отразились и уже на дообученных моделях: как было сказано в начале 3 этапа, что модель видела пары «инструкция безопасный код» и не могла узнать, что безопасность вообще является значимым измерением. bottomk упал даже ниже базового значения, что как и в случае Perplexity, является свидетельством того, что ранние слои для такой задачи бесполезны.
Итоговые выводы:
-
1. Выбор слоев значим, но вторичен. Дообучение улучшило целевую perplexity втрое у любого плеча; различия между плечами: 1–8%.
-
2. Случайный выбор не уступает метрическому. При уравненном бюджете (разброс 0.44%)
random(1.5052) обходит иtopk_actnorm(1.5344), иtopk_gradnorm(1.5238). Разделение полное — худший прогонrandomлучше лучшего прогона любого метрического плеча. -
3. Механизм — разброс по глубине, а не важность. Корреляция разброса индексов с perplexity -0.672. Метрики по построению выбирают смежный блок: они сводят слой к скаляру, а профиль по глубине гладкий. Положение блока тоже значимо:
bottomkиtopk_actnormимеют одинаковый разброс, но различаются на 4.7% из‑за глубины. -
4. Метрика отсеивает плохое, но не находит лучшее.
bottomkзаметно хужеtopk_actnorm— ранжирование несёт сигнал. Но концентрация на «важных» слоях проигрывает распределению по глубине. -
5. Perplexity не свидетельствует об усвоении задачи. Трёхкратное улучшение не вывело предпочтение даже на случайный уровень.
-
6. Размена «домен против общих навыков» не обнаружено — что само по себе указывает: адаптация в основном не доменная.
И что же дальше?
Казалось бы, что полученный отрицательны результат закрывает данный вопрос полностью. Но при детальном разборе первого этапа вывод по результатам, полученным при расчете градиентных норм указывает на продолжение. В выводе первого этапа про gradnorm я сказал, что состав слоев не меняется, при том, что порядок сильно расходится. И главное, что внутри домена данное расхождение воспроизводится, что доказывают результаты расчетов расхождений порядков внутри домена и между доменами. Поэтому, раз доменный сигнал существует, он воспроизводим. А при неизменяющемся составе слоев, логично предположить, что данный сигнал живет на уровне ниже, а именно: на уровне отдельных нейронов. Из этого родилась идея на следующий эксперимент: искать домен‑специфичные нейроны и обучать адаптеры, воздействующие только на них. Хорошая новость в том, что данная проверка требует правки в одну строку, далее проверка по той же самой схеме из 1 этапа, но уже на уровне нейронов. Если между доменами нейронные наборы расходятся сильнее, чем между выборками одного домена — локализация есть, и можно строить маскированную LoRA. Если нет — эксперимент закрыт, как и слойная гипотеза. Все дальнейшие проверки и результаты экспериментов, уже в области поиска локализации доменов на уровне нейронов, я опишу в своей следующей статье.
ссылка на оригинал статьи https://habr.com/ru/articles/1074796/