У меня была видеокарта 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 можно разделить на три части:

  1. Визуальный энкодер Interleaved-MRoPE (это в qwen3vl): по факту это любой VIT или архитектура Clip like

  2. Языковая модель Qwen3-LM: любая LLM

  3. Адаптер 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 пошёл вверх и появились всплески
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

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