Мне не давал покоя вопрос, можно ли на моей нищенской RTX3060 12Gb натренировать свою (не)большую языковую модель. И как оказалось - да, это сделать можно используя Low-Rank Adaptation (LoRA). Т.к. VRAM немного, 8B - это самая большая модель из семейства лама которую можно натренировать на этой карте. Что для этого надо?

1. Грабим данные

У меня была идея сделать модель которая будет помогать с трактовкой карт Таро, поэтому идем и грабим корованы сайты с описанием карт и раскладов таро. Для ограбления я написал небольшой python скрипт и с использованием beautifulsoup4 сохранил результат в отдельные JSON файлы.

2. Генерируем тренировочные сэмплы

Сграбить сырые данные это даже не пол дела, далее из них надо сделать набор данных для обучения. Тут возможны разные варианты в зависимости от задачи. Если модель должна просто генерировать текст по теме - можно тупо скармливать данные как есть, только почистив их от мусора/html тэгов и т.д. Если модель должна отвечать на впросы по теме - из наших данных надо сделать диалоги между человеком и ассистентом.

Обрабатывать вручную одному это невозможно, поэтому используем локальную llm которая работает в ollama. Для генерации данных я использовал python и такой промпт:

Ты эксперт по Таро. На основе предоставленного раздела карты сгенерируй 3-5 вопросов и ответов.
Формат ответа - JSON массив:
{
  "questions" : [
    {
      "question": "текст вопроса",
      "answer": "текст ответа"
    },
  ]
}

Правила:
1. Вопросы должны относиться только к текущему разделу
2. Ответы 2-4 предложения, содержательные
3. Используй профессиональную терминологию
4. Избегай общих формулировок

{CARD_DESCRIPTION}

и примерно такой скрипт

from ollama import Client

MODEL_NAME="phi4"

client = Client("http://localhost:11434")


def generate_qa(card_title, section):
        context = f"""
        Карта: {card_title}
        Раздел: {section['title']}
        Контент: {section['body'][:2000]}  # Обрезка длинных текстов
        """

        response = client.chat(
            model=MODEL_NAME,
            messages=[
                {"role": "system", "content": SYSTEM_PROMPT},
                {"role": "user", "content": context},
            ],
            format="json",
            options={"temperature": 0.5},
        )

        response_data = response.get("message", {}).get("content", "[]")
        qas = json.loads(response_data)
        qa_pairs = qas.get("questions", "")

        # Валидация структуры
        if not isinstance(qa_pairs, list):
            raise ValueError("Некорректный формат ответа")

        return [
            {"question": qa.get("question", ""), "answer": qa.get("answer", "")}
            for qa in qa_pairs
        ]

Попробовал разны модели, для моей задачи лучше всего подошла phi4 от Microsoft. Скармливаем модели наши разделы, получаем ответ, проверяем что это валидный JSON, парсим его и сохраняем в новый набор данных. В результате для каждой карты у меня появились JSON файлы с парами вопрос-ответ:

{
  "title": "Отшельник",
  "qa_pairs": [
    {
      "question": "Какое общее значение имеет карта Отшельник?",
      "answer": "Карта Отшельник символизирует верность себе и необходимость отойти от эмоционально насыщенного образа жизни для внутреннего «исцеления». Она подчеркивает стремление..."
    },
    ...

Плюс я прогнал этот набор еще через одну модель с задачей переформулировать вопрос сохраняя его смысл. В итоге у меня получилось чуть больше 10 тысяч пар вопросов-ответов. Это мало, насколько я понял из прочитанного, для тренировки LoRa неоходимо от 15 тысяч примеров. Поэтому идем и грабим дальше, обрабатываем, сохраняем в нужный формат.

3. Тренировка (попытка №1)

Для тренировки я использовал вот этот jupiter ноутбук от прокекта Unsloth. Он предполагает, что промпт будет в формате Alpaca:

Below is an instruction that describes a task, paired with an input that provides further context. Write a response that appropriately completes the request.

### Instruction:
{}

### Input:
{}

### Response:
{}

Для этого надо преобразовать данные в массив вида

[
  {
    "instruction": "Ты ИИ-эксперт по Таро. Ответь на вопрос используя знания о значениях карт и их интерпретациях.",
    "input": "Что означает аркан Десятка пентаклей?",
    "output": "Десятка пентаклей — это карта, символизирующая материальное изобилие, стабильность ..."
  },
  ...
]

В ноутбуке в разделе train в настройках SFTTrainer комментируем max_steps и добавляем num_train_epochs = N, где N - число эпох.

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

На RTX 3060 мне понадобилось часа 2 чтобы обучить модель одну эпоху. При тестировании, к моему удивллению, модель даже отвечала по теме! Это успех подумал я, сохранил результат, конвертировал его в GGUF и загрузил в ollama.

Тут оказалось, что модель корректно отвечает только на первый вопрос, а на втором начинает выдавать бесконечное полотно текста. Проблема в том, что формат промпта alpaca предполагает, что модель тренируется только на отдельных “инструкция + вопрос + ответ” и ничего не знает о диалогах. Важно сразу понимать в каком режиме должна работать модель и выбирать соответствующий шаблон данных.

3. Тренировка (попытка №2)

Чтобы модель работала в режиме диалога надо пересобрать данные в цепочки вопрос-ответ (SharedGPT). Я опять же использовал локальную модель чтобы сгенерировать наборы диалогов, получился JSON такого вида:

[
  {
    "conversations": [
      {
        "from": "human",
        "value": "Что означает Императрица в раскладе Таро?"
      },
      {
        "from": "gpt",
        "value": "Императрица символизирует творческую, радостную и созидательную фазу развития..."
      },
      {
        "from": "human",
        "value": "Как она влияет на личную жизнь?"
      },
      {
        "from": "gpt",
        "value": "В контексте личной жизни Императрица может предсказывать период стабилизации и гармонизации собственной жизни..."
      },
      ...
    ]
  },
  {
   "conversations" : []

В ноутбуке меняем загрузку данных:

from unsloth.chat_templates import get_chat_template

tokenizer = get_chat_template(
    tokenizer,
    chat_template = "llama-3", # Supports zephyr, chatml, mistral, llama, alpaca, vicuna, vicuna_old, unsloth
    mapping = {"role" : "from", "content" : "value", "user" : "human", "assistant" : "gpt"}, # ShareGPT style
)

def formatting_prompts_func(examples):
    convos = examples["conversations"]
    texts = [tokenizer.apply_chat_template(convo, tokenize = False, add_generation_prompt = False) for convo in convos]
    return { "text" : texts, }
pass

from datasets import load_dataset
dataset = load_dataset("json", data_files="./tarot_dataset.json", split = "train")
dataset = dataset.shuffle(seed=42)  # Перемешиваем
dataset = dataset.map(formatting_prompts_func, batched = True,)

В настройках тренера можно увеличить параметры

per_device_train_batch_size = 4,
gradient_accumulation_steps = 8,
warmup_steps = 50,

и указываем желаемое число эпох num_train_epochs=3.

При таких настройках у меня было Peak reserved memory = 7.699 GB, т.е. использовалось меньше 8ГБ VRAM.

После тренировки сохраняем лору и модель, и можно конвертировать в GGUF:

from unsloth import FastLanguageModel

max_seq_length = 2048  # Choose any! We auto support RoPE Scaling internally!
dtype = (
    None  # None for auto detection. Float16 for Tesla T4, V100, Bfloat16 for Ampere+
)
load_in_4bit = True  # Use 4bit quantization to reduce memory usage. Can be False.


model, tokenizer = FastLanguageModel.from_pretrained(
    model_name="./models/model_16b",
    max_seq_length=max_seq_length,
    dtype=dtype,
    load_in_4bit=load_in_4bit,
)


# Save to 8bit Q8_0
if True:
    model.save_pretrained_gguf(
        "./models/gguf",
        tokenizer,
    )


# Save to 5bit Q5_K_M
if False:
    model.save_pretrained_gguf(
        "./models/gguf", tokenizer, quantization_method="q5_k_m"
    )

# Save to 4bit Q4_K_M
if False:
    model.save_pretrained_gguf(
        "./models/gguf", tokenizer, quantization_method="q4_k_m"
    )

Для конвертации необходимо больше VRAM чем для тренировки, мне приходилось выходить из Gnome и переключаться в tty чтобы освободить память.

Загружаем в ollam GGUF-модель используя такой Modelfile

FROM ./models/gguf_q8/unsloth.Q8_0.gguf

TEMPLATE """<|begin_of_text|>{{ if .Prompt }}<|start_header_id|>user<|end_header_id|>

{{ .Prompt }}<|eot_id|>{{ end }}<|start_header_id|>assistant<|end_header_id|>

{{ .Response }}<|eot_id|>"""

PARAMETER stop "<|start_header_id|>"
PARAMETER stop "<|end_header_id|>"
PARAMETER stop "<|eot_id|>"

PARAMETER temperature 0.5
PARAMETER min_p 0.05
PARAMETER top_p 0.85
ollama create tarot:8b_q8 -f ./Modelfile

Запускаем:

$ ollama run tarot:8b_q8

Тестируем базовую модель:

И тестируем обученную модель:

Видно что базовая модель пишет бессвязную ерунду, а обученная модель текст по теме.

В итоге, несколько дней обработки данных + несколько дней экспериментов, и у нас есть своя модель, которая довольно неплохо отвечает на вопросы по теме.

Выводы

Уже сейчас можно натренировать свою большую языковую модель на достаточно бюджетном устройстве и сделать это быстро.

Большое значение имеет качество и разнообразие данных.

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

Потестировать конечный результат и поговорить с моделью можно в телеграм https://t.me/ai_tarot_oracle_bot или скачать для локального использования с huggingface.

Текущая версия обучена на ~20K примерах 5 эпох.