Мне не давал покоя вопрос, можно ли на моей нищенской 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 эпох.
