У меня была видеокарта NVIDIA A100 с максимальным объёмом памяти 79,254 Гб. Нужно было извлечь ключевую информацию (задача Question Answering) из 6 тыс. многостраничных документов. Всего было 15 полей разного типа:
Фродо_Бэггинс_паспорт — серия и номер паспорта в Средиземье
Сэмуайз_Гэмджи_инн — ИНН, полученный в Мордоре
Хоббит_номер_страховки — номер страхового полиса (эльфийского)
Мериадок_Брендибак_пол — пол
Хоббит_диаметр_кольца — диаметр кольца Всевластия
Перегрин_Тук_вес — вес
Гэндальф_Серый_длина_посоха — длина посоха в сантиметрах
Майар_количество_упоминаний — количество упоминаний в документе его имени
Арагорн_дата_рождения — дата рождения
Леголас_Эльф_количество_стрел — количество стрел
Гимли_фио — ФИО полностью
Боромир_дата_смерти — дата смерти
Саурон_количестов_пальцев — количество пальцев после войны
Орки_количество — сколько орков указано документе
Волки_количество — сколько волков указано в документе
Разумеется, все поля подверглись обфускации. Следует отметить, что по сути это стандартные юридические документы, форма которых различается в зависимости от источника их составления. Особенность заключается в том, что все поля могут быть расположены на одной странице документа или распределены по всему документу, объем которого достигать 80 страниц.
Данные
Итак, 6 тыс. многостраничных документов в формате PDF.


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

Описание этапов фильтрации и дедупликации я опущу.
Промежуточные результаты
После подготовки промпта и определения набора параметров (о них я подробнее расскажу в разделе, посвящённом инференсу), я попытался зерошотнуть стандартным Qwen3-VL-2B-Instruct. Получилось не очень (метрики будут представлены в соответствующей главе).
Дообучение Fintuning Lora
Необходимо было сформировать шаблон, включающий промпт, пути к изображениям и ответы. Такой формат соответствует задаче QA и подходу SFT. Почему именно пути к изображениям? Дело в том, что в обучающем конвейере есть UnslothVisionDataCollator, который автоматически преобразует изображения в токены и выполняет Smart_Resize, как в Transformers. По сути, это расширенная версия Dataset в Torch.
Пример одного элемента в датасете:
{'messages': [{'role': 'user',
'content': [{'type': 'text',
'text': 'ТУТ ВАШ ПРОМТn'},
{'type': 'image',
'image': '/data/4/files/3984869850/3984869850_0.jpeg'},
{'type': 'image',
'image': '/data/4/files/3984869850/3984869850_1.jpeg'},
{'type': 'image',
'image': '/data/4/files/3984869850/3984869850_2.jpeg'},
{'type': 'image',
'image': '/data/4/files/3984869850/3984869850_3.jpeg'},
{'type': 'image',
'image': '/data/4/files/3984869850/3984869850_4.jpeg'},
{'type': 'image',
'image': '/data/4/files/3984869850/3984869850_5.jpeg'},
{'type': 'image',
'image': '/data/4/files/3984869850/3984869850_6.jpeg'},
{'type': 'image',
'image': '/data/4/files/3984869850/3984869850_7.jpeg'},
{'type': 'image',
'image': '/data/4/files/3984869850/3984869850_8.jpeg'}]},
{'role': 'assistant',
'content': [{'type': 'text',
'text': {'Фродо_Бэггинс_пасспорт': '0000 123456,
'Сэмуайз_Гэмджи_инн': '91992888',
'Хоббит_номер_страховки': '№ 3008180341',
'Мериадок_Брендибак_пол': 'оно',
'Хоббит_диаметр_кольца': '10 см',
'Перегрин_Тук_вес': '400кг',
'Гэндальф_Серый_длинна_посоха': 'бесконечность',
'Майар_количество_упоминаний': '10',
'Арагорн_дата_рождения': '«29» октября 2024',
'Леголас_Эльф_количество_стрел': '7702073683',
'Гимли_фио': 'Гимли Шарабан Мухлюев',
'Боромир_дата_смерти': '26 февраля 3019 года Третьей Эпохи',
'Саурон_количестов_пальцев': '19',
'Орки_количество': '30/100/5000',
'Волки_количество': '1010100/20000/300000'}}]}]}
Эксперименты по формированию датасета
В процессе работы возник вопрос: «А что, если использовать только те страницы, на которых есть необходимые поля?» Создав такой датасет, я обнаружил, что максимальное количество страниц на один документ сократилось до шести. Это открывало возможности для увеличения некоторых параметров при дообучении, например, размера батча или разрешения входного изображения. Но, к сожалению, в таком формате модель обучалась плохо и не оправдала ожиданий.
Более подробную информацию о формировании датасета можно найти в документации, в разделе Vision Fine-tuning.
Модель
Давайте немного поговорим об архитектуре модели, чтобы понять, что мы тюним и зачем.
Условно VLM можно разделить на три части:
-
Визуальный энкодер Interleaved-MRoPE (это в qwen3vl): по факту это любой VIT или архитектура Clip like
-
Языковая модель Qwen3-LM: любая LLM
-
Адаптер MLP: промежуточный слой, который преобразует визуальные эмбеддинги в формат, понятный языковой модели
Если хотите глубже изучить архитектуру модели, то можете посмотреть тут.
Подбор параметров и инициализация модели
Важно отметить, что обучение модели проводилось офлайн на отдельной видеокарте с локальной загрузкой. Сама модель была взята отсюда.
# Инициализация
model, tokenizer = FastVisionModel.from_pretrained(
str(QWEN3_2B_VLM), #путь до модели
load_in_4bit = False, # Use 4bit to reduce memory use. False for 16bit LoRA.
use_gradient_checkpointing = "unsloth", # True or "unsloth" for long context
local_files_only = True,
gpu_memory_utilization = 0.95,
)
Тут, я думаю, всё понятно: инициализируем модель с нужной конфигурацией.
# подбор обучаемых слоев и параметров модели
model = FastVisionModel.get_peft_model(
model,
finetune_vision_layers = True,
finetune_language_layers = True,
finetune_attention_modules = True,
finetune_mlp_modules = True,
gradient_checkpointing = True,
r = 16, # The larger, the higher the accuracy, but might overfit
lora_alpha = 32, # Recommended alpha == r at least
lora_dropout = 0,
bias = "none",
random_state = 3407,
use_rslora = true, # We support rank stabilized LoRA
loftq_config = null, # And LoftQ
# target_modules = "all-linear", # Optional now! Can specify a list if needed
)
Остановлюсь подробнее на выборе слоёв для дообучения. Оптимальная конфигурация под вашу задачу подбирается методом проб и ошибок. Я поделюсь тем, что подошло мне.
Слои:
-
finetune_vision_layers
-
layers finetune_language_layers
-
finetune_attention_modules
-
finetune_mlp_modules
В идеале следует включить все слои, чтобы модель понимала весь контекст. Однако если у вас ограничен объем памяти, или вы хотите увеличить батч или max_seq_length (например, чтобы включить все страницы документа в обучение), я бы порекомендовал в первую очередь отключить finetune_vision_layers=False и finetune_mlp_modules=false. Это значительно снизит нагрузку на видеопамять и освободит необходимые ресурсы.
Кстати, можно не использовать слои, а попробовать через Target Modules.
model = FastVisionModel.get_peft_model(
model,
# finetune_vision_layers = config.peft.finetune_vision_layers , # False if not finetuning vision layers
# finetune_language_layers = config.peft.finetune_language_layers , # False if not finetuning language layers
# finetune_attention_modules = config.peft.finetune_attention_modules, # False if not finetuning attention layers
# finetune_mlp_modules = config.peft.finetune_mlp_modules , # False if not finetuning MLP layers
# gradient_checkpointing = config.peft.gradient_checkpointing ,
r = 16, # The larger, the higher the accuracy, but might overfit
lora_alpha = 32, # Recommended alpha == r at least
lora_dropout = 0,
bias = "none",
random_state = 3407,
use_rslora = true, # We support rank stabilized LoRA
loftq_config = null, # And LoftQ
# target_modules = "all-linear", # Optional now! Can specify a list if needed
target_modules = ["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj",],
)
target_modules — это модули, отвечающие за определённые слои модели, которые будут подвергаться обучению: Attention (q_proj, k_proj, v_proj, o_proj) и MLP (gate_proj, up_proj, down_proj).
При желании можно найти файл, который находится внутри самой модели, и посмотреть, за что отвечает каждый слой: model.safetensors.index.json.
"lm_head.weight": "model-00004-of-00004.safetensors",
"model.language_model.embed_tokens.weight": "model-00001-of-00004.safetensors",
"model.language_model.layers.0.input_layernorm.weight": "model-00001-of-00004.safetensors",
"model.language_model.layers.0.mlp.down_proj.weight": "model-00001-of-00004.safetensors",
"model.language_model.layers.0.mlp.gate_proj.weight": "model-00001-of-00004.safetensors",
"model.language_model.layers.0.mlp.up_proj.weight": "model-00001-of-00004.safetensors",
"model.language_model.layers.0.post_attention_layernorm.weight": "model-00001-of-00004.safetensors",
"model.language_model.layers.0.self_attn.k_norm.weight": "model-00001-of-00004.safetensors",
"model.language_model.layers.0.self_attn.k_proj.weight": "model-00001-of-00004.safetensors",
"model.language_model.layers.0.self_attn.o_proj.weight": "model-00001-of-00004.safetensors",
"model.language_model.layers.0.self_attn.q_norm.weight": "model-00001-of-00004.safetensors",
"model.language_model.layers.0.self_attn.q_proj.weight": "model-00001-of-00004.safetensors",
"model.language_model.layers.0.self_attn.v_proj.weight": "model-00001-of-00004.safetensors",
"model.language_model.layers.1.input_layernorm.weight": "model-00001-of-00004.safetensors",
"model.language_model.layers.1.mlp.down_proj.weight": "model-00001-of-00004.safetensors",
"model.language_model.layers.1.mlp.gate_proj.weight": "model-00001-of-00004.safetensors",
"model.language_model.layers.1.mlp.up_proj.weight": "model-00001-of-00004.safetensors",
и т.д.
Если этот файл отсутствует, можно загрузить model.safetensors и вывести его содержимое для ознакомления.
from safetensors.torch import load_file
state_dict = load_file("model.safetensors")
# model.embed_tokens.weight
# model.layers.0.input_layernorm.weight
# model.layers.0.mlp.down_proj.weight
# model.layers.0.mlp.gate_proj.weight
# model.layers.0.mlp.up_proj.weight
# model.layers.0.post_attention_layernorm.weight
# model.layers.0.self_attn.k_proj.weight
# model.layers.0.self_attn.o_proj.weight
# model.layers.0.self_attn.q_proj.weight
# model.layers.0.self_attn.v_proj.weight
Дополнительную информацию о работе Target-Modules и экспериментах по включению слоёв можно найти в соответствующей документации.
Теперь поговорим о параметре use_rslora. Этот параметр помогает стабилизировать обучение. При высоких рангах r и lora_alpha градиенты становятся более стабильными, что минимизирует появление stripe’ов. r— это ранг матрицы адаптера, lora_alpha— коэффициент, регулирующий силу влияния адаптера на модель. В совокупности эти параметры влияют на количество обучаемых параметров адаптера. Например, при r=16 и lora_alpha=32 мы получим Trainable parameters = 34,865,152 of 2,162,397,184 (это количество параметров в qwen3vl-2b) (1,61% trained).
Подробнее о стабилизации rsLora.
Основные параметры обучения
FastVisionModel.for_training(model) # Enable for training!
trainer = SFTTrainer(
model = model,
tokenizer = tokenizer,
data_collator = UnslothVisionDataCollator(
model, tokenizer,
resize = 897,
max_seq_length=40960
),
train_dataset = prep_train_dataset, #train_dataset,
eval_dataset = prep_val_dataset, #eval_dataset,
args = SFTConfig(
per_device_train_batch_size = 1,
gradient_accumulation_steps = 16,
warmup_steps = 10,
# warmup_ratio = 0.03,
max_steps = 150,
num_train_epochs = 1, # max_steps имеет приоритет
learning_rate = 3e-6,
logging_steps = 1,
max_grad_norm = 1,
eval_steps = 10,
per_device_eval_batch_size = 1,
eval_accumulation_steps = 8,
eval_strategy = "steps",
# load_best_model_at_end = True,
do_eval = True,
do_train = True,
optim = "adamw_8bit",
weight_decay = 0.01,
lr_scheduler_type = "cosine",
seed = 3407,
output_dir = "outputs_8b_4bit",
report_to = "none",
# Vision finetuning requirements:
remove_unused_columns = False,
dataset_kwargs = {"skip_prepare_dataset": True},
),
)
Здесь стоит обратить внимание на gradient_accumulation_steps/per_device_train_batch_size:
-
batch_size = 32, gradient_accumulation_steps = 1
-
batch_size = 16, gradient_accumulation_steps = 2
-
batch_size = 8, gradient_accumulation_steps = 4
-
batch_size = 4, gradient_accumulation_steps = 8
-
batch_size = 2, gradient_accumulation_steps = 16
-
batch_size = 1, gradient_accumulation_steps = 32
Согласно документации, размер батча будет эквивалентен при выборе параметров. Однако больший batch_size ускоряет обучение модели, но требует больше видеопамяти, и наоборот. В документации также утверждается, что на «качество» batch_size/gradient_accumulation_steps 16/1 или 1/16 это не влияет.
Обучение
При необходимости, можно добавить early_stopping, но есть ряд причин не делать этого. Обучение не всегда проходит стабильно из-за возможных скачков, что усложняет точную настройку. Рекомендуется ориентироваться на итоговые графики train_loss, val_loss и grad_norm.
early_stopping_callback = EarlyStoppingCallback(
early_stopping_patience = 3, # How many steps we will wait if the eval loss doesn't decrease
# For example the loss might increase, but decrease after 3 steps
early_stopping_threshold = 0.02, # Can set higher - sets how much loss should decrease by until
# we consider early stopping. For eg 0.01 means if loss was
# 0.02 then 0.01, we consider to early stop the run.
)
trainer.add_callback(early_stopping_callback)
Начало обучения
Во многих руководствах можно увидеть, что обучение следует запускать так: trainer_stats = trainer.train(). Однако сами разработчики Unsloth сообщают о багах в Gradient Accumulation. Более надёжным способом является использование следующего подхода:
from unsloth import unsloth_train
# trainer_stats = trainer.train() << Buggy gradient accumulation
trainer_stats = unsloth_train(trainer)
Почему сложно настраивать? Сам Loss может снижаться неравномерно, поэтому остановку обучения лучше настраивать через max_steps. Или же можно попробовать подобрать оптимальные параметры с помощью Optuna, если есть возможность параллельного запуска обучений.
# пример снижения loss
loss
0 1.1491
1 1.1637
2 1.1493
3 1.1617
4 1.1280
5 1.1506
6 1.1389
7 1.1098
8 1.0763
9 1.0576
Когда будете смотреть на Loss, не стоит пугаться начальных значений: он может начинаться как с 3,12, так и с 1,14 (как в моём случае). Главное — следить за тем, чтобы он не слишком резко падал. Хотя здесь всё зависит от вашего обучения и лучше смотреть работу модели уже на инференсе.
Ещё одно важное уточнение: низкое значение loss не всегда гарантирует высокое качество обучения модели. Изначально, ориентируясь только на Train и Eval loss, можно было сделать вывод о стабильности обучения. Но я решил посмотреть на Grad_norm:
Наблюдается рост показателя Grad_norm, что указывает на меморизацию вместо генерализации. Это означает, что модель не обучается, а запоминает. Хотелось бы узнать ваши мнения по этому поводу (пожалуйста, поделитесь в комментариях). После получения неудовлетворительных метрик на этапе инференса, я приступил у настройке параметров для предотвращения подобного поведения.
Статистика по памяти после обучения:
def gpu_stats():
gpu_stats = torch.cuda.get_device_properties(0)
start_gpu_memory = round(torch.cuda.max_memory_reserved() / 1024 / 1024 / 1024, 3)
max_memory = round(gpu_stats.total_memory / 1024 / 1024 / 1024, 3)
print(f"GPU = {gpu_stats.name}. Max memory = {max_memory} GB.")
print(f"{start_gpu_memory} GB of memory reserved.")
GPU = NVIDIA A100 80GB PCIe. Max memory = 79.254 GB.
78.545 GB of memory reserved.
Сохранение модели
Существует два подхода к сохранению модели, зависящих от способа последующего инференса. Можно сохранить отдельно адаптер:
model.save_pretrained("llama_lora") # Local saving
tokenizer.save_pretrained("llama_lora")
Но можно сразу объединить адаптер с основной моделью. При необходимости возможно объединение в 4, 8 и 16 бит, в зависимости от выбранной базовой модели:
model.save_pretrained_merged(
QWEN_FINETUNE/"qwen3vl_2b_vlm_finetune",
tokenizer,
# save_method = "merged_16bit",
push_to_hub=False,
)
При сохранении модели иногда возникают сложности: не все файлы из основной директории подтягиваются автоматически. Эта проблема решается переустановкой библиотеки.
Инференс
Для инференса модели использовался классический VLLM c подбором параметров и промптов. Для этого была подготовлена отдельная выборка документов без ограничений по количеству страниц. Статистика инференса:
### qwen3vl-2b-finetune
Available KV cache memory: 69.12 GiB
GPU KV cache size: 647,152 tokens
Maximum concurrency for 120,000 tokens per request: 5.39x
среднее время на один документ 3.50s/it
### qwen3vl-8b-finetune
Available KV cache memory: 56.42 GiB
GPU KV cache size: 410,816 tokens
Maximum concurrency for 120,000 tokens per request: 3.42x
среднее время на один документ 9.19s/it
Метрики
Ниже представлены метрики для трёх моделей: стандартной qwen3vl-2b, qwen3vl-8b и одной дообученой версий.
### ОБЩИЙ ACCURACY qwen3vl-2b-instruct
### Accuracy: 0.543
| field | correct | total | accuracy |
|--------------------------------|---------|-------|----------|
| Фродо_Бэггинс_пасспорт | 455 | 994 | 0.458 |
| Сэмуайз_Гэмджи_инн | 448 | 994 | 0.451 |
| Хоббит_номер_страховки | 477 | 993 | 0.480 |
| Мериадок_Брендибак_пол | 479 | 990 | 0.484 |
| Хоббит_диаметр_кольца | 517 | 987 | 0.524 |
| Перегрин_Тук_вес | 469 | 977 | 0.480 |
| Гэндальф_Серый_длинна_посоха | 531 | 994 | 0.534 |
| Майар_количество_упоминаний | 475 | 979 | 0.485 |
| Арагорн_дата_рождения | 470 | 968 | 0.486 |
| Леголас_Эльф_количество_стрел | 437 | 943 | 0.463 |
| Гимли_фио | 409 | 943 | 0.434 |
| Боромир_дата_смерти | 457 | 888 | 0.515 |
| Саурон_количестов_пальцев | 360 | 824 | 0.437 |
| Орки_количество | 507 | 993 | 0.511 |
| Волки_количество | 472 | 973 | 0.485 |
### ОБЩИЙ ACCURACY qwen3vl-2b-finetune
### Accuracy: 0.886
| field | correct | total | accuracy |
|--------------------------------|---------|-------|----------|
| Фродо_Бэггинс_пасспорт | 834 | 994 | 0.839 |
| Сэмуайз_Гэмджи_инн | 821 | 994 | 0.826 |
| Хоббит_номер_страховки | 877 | 993 | 0.883 |
| Мериадок_Брендибак_пол | 879 | 990 | 0.888 |
| Хоббит_диаметр_кольца | 952 | 987 | 0.965 |
| Перегрин_Тук_вес | 862 | 977 | 0.882 |
| Гэндальф_Серый_длинна_посоха | 975 | 994 | 0.981 |
| Майар_количество_упоминаний | 873 | 979 | 0.892 |
| Арагорн_дата_рождения | 865 | 968 | 0.894 |
| Леголас_Эльф_количество_стрел | 804 | 943 | 0.853 |
| Гимли_фио | 754 | 943 | 0.800 |
| Боромир_дата_смерти | 842 | 888 | 0.948 |
| Саурон_количестов_пальцев | 661 | 824 | 0.802 |
| Орки_количество | 931 | 993 | 0.938 |
| Волки_количество | 864 | 973 | 0.888 |
### ОБЩИЙ ACCURACY qwen3vl-8b-instruct
### Accuracy: 0.833
| field | correct | total | accuracy |
|--------------------------------|---------|-------|----------|
| Фродо_Бэггинс_пасспорт | 818 | 994 | 0.823 |
| Сэмуайз_Гэмджи_инн | 852 | 994 | 0.857 |
| Хоббит_номер_страховки | 827 | 993 | 0.833 |
| Мериадок_Брендибак_пол | 841 | 990 | 0.850 |
| Хоббит_диаметр_кольца | 829 | 987 | 0.840 |
| Перегрин_Тук_вес | 839 | 977 | 0.859 |
| Гэндальф_Серый_длинна_посоха | 875 | 994 | 0.880 |
| Майар_количество_упоминаний | 840 | 979 | 0.858 |
| Арагорн_дата_рождения | 793 | 968 | 0.819 |
| Леголас_Эльф_количество_стрел | 830 | 943 | 0.880 |
| Гимли_фио | 794 | 943 | 0.842 |
| Боромир_дата_смерти | 729 | 888 | 0.821 |
| Саурон_количестов_пальцев | 658 | 824 | 0.799 |
| Орки_количество | 745 | 993 | 0.751 |
| Волки_количество | 778 | 973 | 0.800 |
Вывод
Современный подход часто заключается в использовании готовых моделей, настройке промптов с помощью zeroshot-подхода и подборе моделей под конкретную задачу, либо в выборе более крупных моделей (на 30 млрд параметров). Однако можно собрать небольшой набор данных и дообучить более лёгкую модель под свои задачи, что и было продемонстрировано. К тому же использование более лёгкой модели позволяет сэкономить ресурсы и создавать множество адаптеров для различных задач.
Хотелось бы не только поделиться своим опытом Fintune Lora, но и услышать ваши истории в комментах.
Спасибо за прочтение!
Материалы
https://arxiv.org/pdf/2305.14314
https://arxiv.org/pdf/2312.03732
https://habr.com/ru/articles/931382/
https://unsloth.ai/blog/gradient
https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/lora-hyperparameters-guide
Автор: piexpanenta


