Введение

Всем привет, Хабр! В своей первой статье хотел бы рассказать о своем селф‑эдьюкейтинге в сфере машинного обучения, а именно о нескольких подходах к 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 — норма активации по одному батчу (для батча x_t и слоя ℓ):

a_ℓ(x_t ​  )=  \frac{1}{T(x_t)} ​\sum_{i=1}^{T(x_t)}||h_ℓ (x_t)_i||_2 ​

Шаг 2: Накопление по S пробным батчам (при начальном условии A_ℓ = 0):

A_ℓ ← A_ℓ+a_ℓ(x_t ​  ),t=1,…,S

Шаг 3: Усреднение по пробному датасету:

A_ℓ = \frac{1}{∣D_{probe}∣} \sum_{t=1}^{S} a_ℓ(x_t)= \frac{1}{S} \sum_{t=1}^{S} a_ℓ (x_t)

Шаг 4: min‑max нормировка в [0, 1]:

s_ℓ ​  =  \frac{A_ℓ - \min\limits_{j \in L}A_j}{\max\limits_{j \in L}A_j - \min\limits_{j \in L}A_j}, ℓ \in L

Шаг 5: Отбор топ‑К слоев:

L_{sel} = \text{top-K}(\left\{s_ℓ\right\}_{ℓ \in L})

Обозначения:

  • L— Множество слоев‑кандидатов

  • ℓ— индекс слоя, ℓ \in L

  • S— число пробных мини‑батчей

  • x_t— мини‑батч, сэмплированный из датасета целевой задачи

  • D_{probe}— Множество всех пробных батчей, где |D_{probe}| = S

  • h_ℓ(x_t)— Множество активаций слоя ℓ на батче x_t

  • h_ℓ(x_t)_i— Вектор активации i‑го токена

  • T(x_t)— Размер батча в токенах (длина последовательности).

  • A_ℓ— Усредненная оценка активности слоя

  • s_ℓ— Нормированная важность, где s_ℓ \in [0, 1]

  • L_{sel}— Отобранное подмножество слоев под адаптеры.

Выводы авторов:

В разделе 7.1 авторы говорят о том, что адаптация топ‑к сохраняет большую часть качества при снижении стоимости обучения и инференса, та же здесь авторы дают оговорку, что эффект сильно зависит от размера датасета и деградация заметнее на малоресурсных задачах GLUE. Вывод раздела: метод пригоден для средне и высокоресурсных наборов, где данных хватает компенсировать сниженную емкость. Основные цифры из таблицы 5 статьи Act‑LoRA:

Модель

k

\DeltaGLUE

Экономия 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 — \tau

DeBERTaV3-Base

activation norms

1.0000 \pm 0.0000

DeBERTaV3-Base

gradient norms

0.9387 \pm 0.0489

LLaMA-3.1–8B

activation norms

0.9190 \pm 0.0608

LLaMA-3.1–8B

gradient norms

0.6384 \pm 0.1942

В разделе 7.4 авторы отмечают, что важность слоев определяется архитектурой модели, а не семантикой задачи, и подают это как достоинство, говоря о том, что метрику не нужно пересчитывать под каждый датасет. Данный вывод практически сразу дает ответ на мой вопрос о зависимости активации слоев от тематики задачи. Так же в разделе 8, пункте 2 авторы пишут о вычислительных ограничениях, которые не позволили им проверить модели выше 8B. Собственно в моем случае те же самые ограничения не позволили мне выбрать модель с бОльшим числом параметров.

Сводка различий

По итогу во время прочтения статьи Act‑LoRA я для себя выявил следующие пробелы в методологии авторов, которые я учел в своем исследовании:

  • 1) Отсутствие случайного контроля. В статье Act‑LoRA нет сравнения с LoRA на случайно выбранных k слоях, без такого контроля нельзя заранее сказать, что информативный выбор превосходит случайный. В моем случае будет итоговое сравнение с применением адаптеров к случайному набору k слоев.

  • 2) Параметры не уравнены. В Act‑LoRA сравнивается конфигурация с 150К и 300К обучаемых параметров. Поэтому просадка качества может объясняться меньшим размером адаптера, а не выбором слоев. В моем случае будет пересчитан ранг так, чтобы бюджет совпал.

Так же в своем исследовании я ввел метод выбора слоев по градиенту лосса. Если сравнивать его с активацией, то активация — это свойство прямого прохода, оно определяется весами и структурой стека (что подтверждено в статье Act‑LoRA), а градиент — это свойство расхождения предсказания с целью, то есть он напрямую зависит от данных.

G_\ell = \left|\frac{\partial L}{\partial W_q^{(\ell)}}\right|F + \left|\frac{\partial L}{\partial W_v^{(\ell)}}\right|F, \qquad L = -\frac{1}{T-1}\sum_i \log p(x{i+1} \mid x{\le i})

Где\left\|\cdot\right\|_F— норма Фробениуса, а не L2 вектора.

Смысл данной величины в том, чтобы понять, насколько лосс «требует» подвинуть веса этого слоя: большой градиент означает, что модель плохо справляется с данными именно здесь.

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

Хочется отметить еще то, что в разделе 7.3 статьи Act‑LoRA наблюдение показывает, что градиентные нормы менее стабильны между сидами (значение Kendall — \tau в таблице 9). Авторы объясняют это как шум оптимизации, я же предполагаю, что градиенты реагируют на данные. Так же авторы не проводили измерения разброса между задачами, а только между сидами, поэтому вопрос: «Различают ли градиенты домены?» остается открытым.

В моем случае я измерял два типа согласованности ранжирований:

  • Между сидами внутри домена (шум)

  • Между доменами при фиксированном сиде (сигнал)

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

Этап 1: Проверка на различие метриками темы

Прежде чем начать обучать что‑либо нужно проверить исходную гипотезу о том, что могут ли метрики различать домены? Основная ставка была сделана на градиентные нормы. Ключевое требование дизайна — разделить тему и форму текста. Основная пара корпусов была подобрана из одного жанра: научные абстракты сопоставимой длиный и регистра.

Корпус

Источник

Объем

Медиана симв.

Роль

Medicine

PubMed (MedRAG)

500

1078

Тема А

Physics

arXiv, только Physics Archive

500

975

Тема В

Code_python

codeparrot/github‑code

500

1454

Контраст по теме

Control

wikitext-103

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)

# Служебные пометки старых записей PubMed
PUBMED_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 random
import numpy as np
import torch

def 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 random
import numpy as np
import torch

def 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.

\text{Kendall's} \text{ }\tau = \frac{2(n_c-n_d)}{n(n-1)}

Где n_c/n_d — число согласованных / несогласованных пар, n— количество слоев.

\text{Jaccard Index - } J(A, B) = \frac{A \cap B}{A \cup B}

Где A и B— множества из топ 10 слоев двух разных доменов.

Корреляция Кендалла сравнивает весь порядок, то есть рассчитывается важность между всеми парами 28 слоев (378 пар). В моем случае результат будет показывать насколько коррелируют между собой слои каждой метрики на каждой паре доменов.

Мера Жаккара показывает насколько похожи наборы слоев на различных доменах каждой метрики (в моем случае, расчет индекса идет не для всего набора из 28 слоев, а лишь для топ 10). Именно данный показатель скажет насколько сильно различаются слои на доменах (и различаются ли вообще).

На каждой метрики были рассчитаны оба показателя для следующих пар доменов: медицина — физика (отличаются только темой), медицина — код и физика — код (отличаются темой и формой):

Пара доменов

\tau(actnorm)

J(actnorm)

\tau(gradnorm)

J(gradnorm)

medicine\leftrightarrow physics

+ 0.981

1.000

+ 0.832

0.879

medicine\leftrightarrowcode

+ 0.968

1.000

+ 0.811

0.768

physics\leftrightarrowcode

+ 0.963

1.000

+ 0.792

0.828

Для более достоверного результата дополнительно были проделаны шаги 2 и 3 еще на двух сидах.

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

Метрика

Шум

Сигнал

Разрыв

actnorm

0.995

0.971

+ 0.025

gradnorm

0.965

0.812

+ 0.153

delta

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) одинаковое для всех доменов: J показывает, что 8 из 10 слоев присутствуют во всех прогонах на всех доменах. Однако порядок внутри топ 10 набора меняется сильно, как видно из последней таблицы: \tau падает с 0.960 до 0.654. Так же хочется сказать, что 0.960 — это шум внутри домена по тому же порядку, а при переходе к другому домену ранжирование заметно расходится. Будь это пограничным шумом, то просело бы и внутридоменное значение. Из этого всего следует, что доменный сигнал существует, но живет он в относительных весах внутри набора слоев, а не в каких‑то отдельных слоях.

Этап 2: Сравнение стратегий выбора слоев

Поскольку на первом этапе выявить тематическую локализацию слоев не удалось, но при этом градиентные нормы выделяют слои из середины, в отличии от L2 норм, которые выделяют 10 последних слоев, вопрос переформулирован: отличается ли выбор слоев между этими двумя метриками и отличается ли выбор слоев по этим двум метрикам от случайного при равном бюджете? Во втором этапе я дообучаю дефолтую модель на пяти конфигурациях, где каждая конфигурация имеет 3 сида, за исключением random, которая имеет 5 сидов. 5 сидов в random объясняются тем, что у нас на каждом сиде меняются не только стартовые значения весов адаптеров после инициализации, но и набор 10 слоев для них. После дообучения замеряется Perplexity каждого сида каждой конфигурации и считается его разброс.

Плечо

Принцип выбора

Роль

full

все 28 слоев

Стандартная LoRA, точка сравнения

topk_actnorm

10 слоев с наибольшими нормами активации

Воспроизведение метода из Act‑LoRA

topk_gradnorm

10 слоев с наибольшими нормами градиентов

Альтернативный сигнал важности

random

10 случайных слоев

Критический контроль

bottomk

10 слоев с наименьшими нормами активации

Обратный контроль

Число обучаемых параметров LoRA линейно по рангу и числу адаптируемых слоев. Чтобы конфигурация на k слоях имела тот же бюджет, что и полная, ранг пересчитывается. Без данной поправки сравнение измеряло бы размер адаптера, а не стратегию выбора слоев:

r_k=\text{round}(r_{base}\frac{L}{k})=\text{round}(16 \frac{28}{10})=45r_k=\text{round}(r_{topk}\frac{L}{k})=\text{round}(45 \frac{10}{10})=45

В качестве датасета для обучения был выбран датасет: CyberNative/Code_Vulnerability_Security_DPO, содержащий 4656 исходных строк на 11 языках. При первичной обработки были выявлены и удалены ~500 дублирующихся строк, все строки отфильтрованы по длине символов с условием: promt + completion \le2400 символов, так же при составлении train и eval следим за тем, чтобы выборки были сопоставимы по составу языков. По итогу в датасете осталось 4136 пригодных строк.

import json
import random
from pathlib import Path

HERE = Path(__file__).parent
SRC = 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_keys
print(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 индексов

full

0 — 27

8.08

random

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

topk_actnorm

18–27

3.20

topk_gradnorm

9, 11, 12, 13, 14, 15, 16, 17, 18, 20

2.87

bottomk

0–9

2.87

При средней скорости прогона на моем железе в ~4 сек. / шаг, один прогон занимал от полутора до двух часов. После всех 17 прогонов и замеров на каждом Perplexity, получается следующий результат:

Рис. 1: Целевой Perplexity, замеренный на eval выборке
Рис. 1: Целевой Perplexity, замеренный на eval выборке
 Рис. 2: Зависимость Perplexity от разброса слоев по глубине
Рис. 2: Зависимость Perplexity от разброса слоев по глубине

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

\mathrm{PPL} = \exp\left(-\frac{1}{N}\sum_{i=1}^{N} \log p(x_i \mid x_{<i})\right)

Где N — число оцениваемых токенов, а p(x_i \mid x_{<i}) — вероятность, которую модель приписывает токену x_i при данном префиксе.

Для М примеров — взвешивание по числу токенов:

\mathrm{PPL} = \exp\left(\frac{\sum_{j=1}^{M} \mathrm{NLL}j}{\sum_{j=1}^{M} n_j}\right)

где

\mathrm{NLL}_j = \sum_{i \in \text{ответ}j}^{} -\log p(x_i \mid x_{<i})

Вывод по 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: Общий бенчмарк
Рис. 3: Общий бенчмарк
Рис. 4, 5: Perplexity каждого плеча на нейтральеом тексте, HellaSwag метрики плечей
Рис. 4, 5: Perplexity каждого плеча на нейтральеом тексте, HellaSwag метрики плечей
Рис. 6: Зависимость Perplexity на нейтральном тексте от Perplexity на целевом домене
Рис. 6: Зависимость Perplexity на нейтральном тексте от Perplexity на целевом домене
Рис. 7: Метрики предпочтений каждого плеча
Рис. 7: Метрики предпочтений каждого плеча
Рис. 8: Зависимость Perplexity от метрики предпочтения
Рис. 8: Зависимость Perplexity от метрики предпочтения

Вывод по 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. Если нет — эксперимент закрыт, как и слойная гипотеза. Все дальнейшие проверки и результаты экспериментов, уже в области поиска локализации доменов на уровне нейронов, я опишу в своей следующей статье.

Комментарии (0)