У меня была видеокарта 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-instruct 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