Главная / Статьи / Стоит ли брать LoRA побольше? Сравнивайте точность с ценой, а не с ощущениями

Стоит ли брать LoRA побольше? Сравнивайте точность с ценой, а не с ощущениями

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

1242 слов

Папка с тринадцатью экспериментами

Представьте папку с тринадцатью экспериментами по дообучению. Там есть перенос стиля, извлечение JSON, маршрутизация инструментов, настройка предпочтений для сообщений коммитов, дистилляция цепочки рассуждений и мультимодальный прогон, который читает столбчатые диаграммы. Всё это LoRA поверх Llama на 1B, обученная локально на Mac с M-чипом через MLX. Рабочий процесс изменил эксперимент номер двенадцать. Он не учит модель ничему новому. Он просто как следует измеряет все остальные.

Вопрос, на который отвечают интуицией

Возьмём задачу классификации. Это могла быть маршрутизация тикетов поддержки. Здесь же нужно было размечать pull request'ы на GitHub категориями conventional commits: bug, feat, docs, refactor, test, chore, perf и style. Получается восемь классов с аккуратно разделёнными обучающей и тестовой выборками.

Вы дообучаете небольшую модель, и она работает. Дальше возникает вопрос, который возникает всегда: а ранг 16 не окажется лучше ранга 4?

Обычно на него отвечают рассуждением. Ранг задаёт размерность низкорангового обновления, больше ранга — больше ёмкости, классов восемь и есть языковые тонкости, значит, наверное, да. Ранг повышают, точность сдвигается на величину, которую легко списать на шум, и изменение остаётся, потому что время уже потрачено. Это не инженерия. Это догадка с приложенным конфигом.

Настоящая проблема была в том, что никто не спрашивал, окупает ли себя адаптер побольше, а для этого нужно на что-то делить. Адаптер ранга 16 на 16 слоях несёт примерно в восемь раз больше обучаемых параметров, чем ранг 4 на 8 слоях. Он дольше обучается, дольше сливается с весами, и выкатывать приходится уже другой артефакт. Если он даёт один пункт точности, сделка плохая. Если пятнадцать, решение очевидно. Пока сторону затрат никто не измерял, понять, какой из случаев ваш, было невозможно.

Рецепты как обычные данные

Решением стал стенд, построенный на скучной идее, и в этом весь смысл: конфигурация обучения — это данные, а не код.

RECIPES = [
    {"name": "base-zs",      "train": None, "shots": 0},
    {"name": "base-fs",      "train": None, "shots": 4},
    {"name": "lora-r4-l8",   "train": {"lora_rank": 4,  "num_layers": 8,  "iters": 600}},
    {"name": "lora-r16-l16", "train": {"lora_rank": 16, "num_layers": 16, "iters": 600}},
    {"name": "lora-long",    "train": {"lora_rank": 8,  "num_layers": 8,  "iters": 1500}},
]

Пять элементов списка, и всё. Для каждого прогон делает всё сам: обучает адаптер, вливает его в базовые веса, добавляет слитую модель в Ollama через сгенерированный Modelfile, оценивает её на отложенной тестовой выборке и записывает результат.

Два рецепта вообще обходятся без обучения. Так задумано, и именно эту идею стоит перенять в первую очередь. base-zs — это исходная модель на 1B в режиме zero-shot. base-fs — та же модель с четырьмя примерами в системном промпте:

FS_EXAMPLES = (
    "Title: fix crash when checkout receives empty payload\nBody: Guard added.\nLabel: bug\n\n"
    "Title: add bulk delete to dashboard\nBody: New endpoint + UI.\nLabel: feat\n\n"
    "Title: bump dependencies for billing\nBody: Routine maintenance.\nLabel: chore\n\n"
    "Title: speed up search list query with index\nBody: p95 480ms -> 120ms.\nLabel: perf\n"
)

Few-shot почти бесплатен: промпт чуть длиннее и минут десять на то, чтобы написать примеры. Дообучение же тянет за собой прогон обучения, слитый артефакт модели, историю с деплоем и обязательство переобучать модель при каждом изменении набора меток. Не сравнив с бесплатным вариантом, нельзя утверждать, что дорогой был нужен. Ни в одном из одиннадцати предыдущих проектов не было few-shot-базы, и задним числом это выглядит неловко.

Четыре числа на каждый рецепт

Каждый рецепт получает четыре измерения, и польза в том, чтобы видеть их рядом:

  • Точность, ради которой всё это и затевается
  • Время обучения — реальное время, замеренное через perf_counter вокруг настоящего подпроцесса
  • Задержка инференса — медиана по всей тестовой выборке, а не среднее
  • Пиковая память, по которой видно, поместится ли всё это на вашей машине

Время измеряется, а не оценивается на глаз:

t0 = time.perf_counter()
subprocess.run(cmd, check=True)
train_secs = time.perf_counter() - t0

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

На M1 Pro полный прогон занимает около 45 минут, и в этом главный выигрыш. Сорок пять минут без присмотра — это настолько дёшево, что запустить прогон проще, чем спорить, и это меняет привычки. Мнения о гиперпараметрах уступают место таблице.

Что прогон умеет разделить

Неожиданная находка вообще не была связана с каким-то одним числом. Рецепт lora-long (ранг 8, 8 слоёв, 1500 итераций) нужен, чтобы развести две переменные, которые постоянно путают. «LoRA побольше помогла» обычно означает, что ранг подняли и обучение заодно шло дольше, потому что конфиг поменяли один раз и посмотрели на один результат. Это разные рычаги. Больший ранг добавляет ёмкости, а больше итераций — значит дальше сходиться в рамках уже имеющейся ёмкости. Задаче может не хватать одного и быть в избытке другого, и лечение в этих случаях совершенно разное.

Зафиксируйте ранг 8 и поднимите итерации с 600 до 1500 — эффект изолирован. Вы платите в два с половиной раза большим временем обучения при том же размере артефакта и той же цене инференса. Если результат остаётся близким к прогону на 600 итераций, задача никогда не упиралась в ёмкость, и ранг был ложным следом. Если он резко растёт, модель всё это время была недообучена, а спор о ранге крутился вокруг не той ручки. Сколько ни смотри на конфиг, ответа на этот вопрос там нет.

Короткий итоговый абзац в отчёте выбирается по простым правилам: он называет рецепт с лучшей точностью и рецепт с лучшей точностью на секунду обучения. Два разных победителя — нормальный исход. Когда названы оба, решение приходится принимать по-настоящему, а правило «побеждает наибольшее число» тихо приняло бы его за вас.

Ограничение, о котором стоит сказать

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

Структура при этом сохраняется. Рецепты остаются данными, цена меряется по тем же часам, что и качество, а в таблице по-прежнему четыре столбца. Усложняется только функция точности.

К чему всё сводится

Разговоры о дообучении в основном крутятся вокруг качества, хотя большинство реальных решений касается цены. Ранг, слои и итерации стоят по-разному. Чтобы честно их сравнить, нужен стенд, который запускается так быстро, что спорить вместо этого никто не станет. 45-минутная задача без присмотра дешевле уверенного инженера и ошибается реже, так что сначала постройте таблицу, а мнение составьте потом.