Всем привет дорогие читатели!
В данном руководстве я бы хотел рассказать и показать вам, как дообучить 7-миллиардную модель Сайги Ильи Гусева @Takagi под свои задачи. Данная статья является своеобразным туториалом по дообучению модели на Kaggle. Однако в наше время написано много статей про дообучение LLM, но все они в большинстве англоязычные или авторы используют свои вычислительные мощности загоняя в тупик тех, у кого возможности обучать на своём «железе» нет.
Начнём пожалуй с вопроса «Почему Kaggle, а не Google Collab?»
Преимущество Kaggle над Google Collab для меня заключается в том, что вычислительные мощности, которые представляет данная платформа, выдаются на 30 часов каждую неделю (если говорить про GPU). Плюс Notebook, в котором вы сейчас работаете, не завершит свою работу в течении 12 часов (если какой-то код там выполняется), в отличие от Collab`а, который может вам завершить работу если вы решили отдохнуть и запустили код на обучение.
Выделенного времени более чем достаточно если вы изначально создадите все скрипты для обучения вашей модели.
Датасет
Как должен выглядеть свой датасет для обучения? Этим вопросом я задавался постоянно, когда приходилось обучать любую LLM. И в один момент пришло осознание.
Для начала разберём, что на вход подаётся модели. Важный момент! Не то, какой промт вы ей передаёте, а то какой формат имеет системный, пользовательский промт и ответ модели.
Для этого я использовал код, который был предложен на Hugging Face для модели Сайги от Ильи Гусева. По хорошему можно было разобраться в файлах обучения LLama на GitHub проекта rulm и посмотреть как был «предобработан» датасет для обучения модели, но спасибо автору, что он в карточке модели предоставил код для её запуска.
Ссылка на код для проведения данного эксперемента
Итак, нас интересует строка 9 в методе generate.
def generate(model, tokenizer, prompt, generation_config):
data = tokenizer(prompt, return_tensors="pt")
data = {k: v.to(model.device) for k, v in data.items()}
output_ids = model.generate(
**data,
generation_config=generation_config
)[0]
output_ids = output_ids[len(data["input_ids"][0]):]
output = tokenizer.decode(output_ids, skip_special_tokens=True)
return output.strip()
А именно output и параметр skip_special_tokens, который отвечает за вывод специальных токенов (о них поговорим ниже). Изменяем значение на False. Далее для наглядности напишем метод для взаимодействия с классом Conversation.
def get_message(inputs):
conversation = Conversation()
conversation.add_user_message(inputs)
prompt = conversation.get_prompt(tokenizer)
print('Промт', '\n', '*'*100)
print(prompt)
print('*'*100)
output = generate(model, tokenizer, prompt, generation_config)
return output
В данном методе мы создаем объект класса, вызываем добавление пользовательского промта и получаем промт, который передается в модель, но мы его перед этим выведем.
Теперь нам нужно сформировать любой запрос, чтобы посмотреть формат промта для модели и ответа модели.
На рисунке видно, что мы не просто передаем запрос и разделяем все промты специальными токенами.
bos_token_id (Beginning of Sequence token ID) - начало последовательности.
eos_token_id (End of Sequence token ID) - конец последовательности.
На рисунке 1 видно что предобработанная строка с запросом заканчивается на “... </s><s>bot”, а ответ модели заканчивается на “... </s>”. Именно поэтому модель понимает, где начать генерировать текст (с токена “bos_token_id”) и в конце своей генерации ставит токен “eos_token_id”.
Данные токены заложены в конфигурации токенизатора, это можно проверить с помощью кода ниже.
print(tokenizer.bos_token_id)
print(tokenizer.eos_token_id)
print(tokenizer.decode([1]))
print(tokenizer.decode([2]))
На выходе мы получаем
В промте также присутствуют слова “system”, “user” и “bot”, которые идут сразу же после токена “bos”. Их тоже надо будет учитывать при создании датасета. Токенизация представлена на рисунке 3.
Теперь подытожим и соберём всё вместе, чтобы понять на каких строках будет обучатся наша модель.
"""<s>system
Тут какой-то системный промт</s><s>user
Тут мы задаём запрос</s><s>bot
Тут модель генерирует текст</s>"""
Вот и всё, пример одного экземпляра датасета уже сделан (шутка нет).
Осталось подготовить наши данные в определенном формате и уже можно создавать методы для предобработки датасета.
Какой формат датасета можно использовать?
Для удобства можно составить «неправильный” json, в котором будут записаны все наши данные.
Формат датасета в двух словах. Это json в котором каждая новая строка представляет собой словарь с ключами “system”, “user” и “bot”. Значение для каждого ключа заполняем системным, пользовательским и ответом бота соответственно.
В конечном итоге у вас должен получится примерно такой вид
Я же создал датасет разделил его на 2 файла: train.json и val.json. Файл валидации служит для оценки модели на i-ой эпохе и проверки переобучения модели.
Подготовим ноутбук для предобработки и обучения модели
Для начала загружаем модель LLama2 и адаптер LoRa (обязательно ставим параметр is_trainable = True)
MODEL_NAME = "IlyaGusev/saiga2_7b_lora"
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
st_time = time.time()
# bnb_config = BitsAndBytesConfig(
# load_in_4bit=True,
# bnb_4bit_quant_type="nf4",
# bnb_4bit_compute_dtype= torch.float16,
# bnb_4bit_use_double_quant=False,
# )
config = PeftConfig.from_pretrained(MODEL_NAME)
model = AutoModelForCausalLM.from_pretrained(
config.base_model_name_or_path,
load_in_8bit = True,
torch_dtype=torch.float16,
device_map="auto",
# quantization_config=bnb_config
)
model = PeftModel.from_pretrained(
model,
MODEL_NAME,
torch_dtype=torch.float16,
# torch_dtype=torch.bfloat16,
is_trainable = True,
# device_map="auto"
).to(device)
model.eval()
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, use_fast=False)
generation_config = GenerationConfig.from_pretrained(MODEL_NAME)
print(generation_config)
print(f'Прошло времени {time.time() - st_time}')
Теперь загрузим наши данные с помощью метода load_dataset
data = load_dataset(
"json",
data_files={
'train' : '/kaggle/input/fine-tunning-llama/train.json' ,
'validation' : '/kaggle/input/fine-tunning-llama/val.json'
}
)
data
Cоставим метод для предобработки данных, чтобы каждый элемент датасета был словарём с ключами input_ids, attention_mask, labels
CUTOFF_LEN = 3584
def generate_prompt(data_point):
promt = f"""<s>system
{data_point['system']}</s><s>user
{data_point['user']}</s><s>bot
{data_point['bot']}</s>"""
# print(promt)
return promt
def tokenize (prompt, add_eos_token=True):
result = tokenizer(
prompt,
truncation=True,
max_length=CUTOFF_LEN,
padding=False,
return_tensors=None,
)
if (
result["input_ids"][-1] != tokenizer.eos_token_id and len(result["input_ids"]) < CUTOFF_LEN
and add_eos_token
):
result["input_ids"].append(tokenizer.eos_token_id)
result["attention_mask"].append(1)
result["labels"] = result["input_ids"].copy()
return result
def generate_and_tokenize_prompt(data_point):
full_prompt = generate_prompt(data_point)
tokenized_full_prompt = tokenize(full_prompt)
# print(tokenized_full_prompt)
return tokenized_full_prompt
Сделаем предобработку для каждого элемента
train_data = (
data["train"].map(generate_and_tokenize_prompt)
)
val_data = (
data["validation"].map(generate_and_tokenize_prompt)
)
Далее задаём параметры обучения
per_device_train_batch_size - размер батча на каждую видеокарту (так как у нас она одна, все вычесления будут происходить на ней)
gradient_accumulation_steps - накапливание градиента для экономии видеопамяти
max_steps - количество шагов обучения
learning_rate - скорость обучения
fp16 - используем точность fp16. О этом параметре можно почитать тут
logging_steps - вывод метрики потерь через данное количество шагов, которое здесь зададим
optim - оптимизатор
evaluation_strategy - стратегия вычислений, так как у нас стоят шаги (steps), то записываем steps
eval_steps - подсчет метрики потерь каждые N шагов, которые здесь укажем
save_steps - параметр сохранения чекпоинтов обучения через каждые N раз, которые здесь задаём
output_dir - параметр для сохранения модели и чекпоинтов
save_total_limit - количество сохранённых чекпоинтов
load_best_model_at_end - загрузка лучшей модели после обучения
report_to - сохранить историю обучения на какой-либо сервис
overwrite_output_dir - перезапись директории для сохранения модели
Мои параметры обучения
BATCH_SIZE = 4
MICRO_BATCH_SIZE = 2
GRADIENT_ACCUMULATION_STEPS = BATCH_SIZE // MICRO_BATCH_SIZE
LEARNING_RATE = 3e-4
TRAIN_STEPS = 100
OUTPUT_DIR = "/kaggle/working/tmp"
training_arguments = transformers.TrainingArguments(
per_device_train_batch_size=MICRO_BATCH_SIZE,
gradient_accumulation_steps=GRADIENT_ACCUMULATION_STEPS,
# warmup_steps=200,
max_steps=TRAIN_STEPS,
learning_rate=LEARNING_RATE,
fp16=True,
logging_steps=10,
optim="adamw_torch",
evaluation_strategy="steps",
save_strategy="steps",
eval_steps=10,
save_steps=10,
output_dir=OUTPUT_DIR,
save_total_limit=10,
load_best_model_at_end=True,
report_to=None,
overwrite_output_dir=True, # Overwrite the content of the output dir
)
Далее выбираем data_collator для пакетной обработки наших данных (DataCollatorForSeq2Seq). И в конечном итоге запускаем обучение
data_collator = transformers.DataCollatorForSeq2Seq(
tokenizer, pad_to_multiple_of=8, return_tensors="pt", padding=True
)
trainer = transformers.Trainer(
model=model,
train_dataset=train_data,
eval_dataset=val_data,
args=training_arguments,
data_collator=data_collator
)
model = torch.compile(model)
trainer.train()
model.save_pretrained(OUTPUT_DIR)
Готово!!
Обучение завершено и можно запушить наш дообученный адаптер на Hugging Face
from huggingface_hub import notebook_login
notebook_login()
model.push_to_hub("Your project", use_auth_token=True)
Конвертирование модели в GGUF формат или как запустить модель на «калькуляторе»
Если вы хотите чтобы ваша модель работала и занимала меньше памяти и чтобы она запускалась у вас на «калькуляторе» с 6 гигабайтами RAM, то для этого необходимо будет квантовать вашу модель.
Важно упомянуть, что квантование модели будет означать небольшую потерю точности. В данном случае будем квантовать модель до 4 бит. О том как квантовать модель оставлю ссылку в конце статьи.
Для начала мы должны склеить наши дообученные слои LoRa с нашей моделью.
Клонируем репозитории rulm и llama.cpp
Далее прописываем необходимые пути:
self_instruct_dir - путь до self_instruct в проекте rulm
checkpoint - чекпоинт нашей лучшей модели
merged_model_name - куда мы хотим сохранить итоговую модель в формате PyTorch
Далее переходим в папку self_instruct_dir и вызываем скрипт convert_to_native нашими параметрами
Пример параметров
self_instruct_dir = '/kaggle/working/rulm/self_instruct'
checkpoint = "/kaggle/working/tmp/checkpoint-40"
merged_model_name = '/kaggle/tmp/merged_test_model.pt'
%cd {self_instruct_dir}
!python -m src.tools.convert_to_native {checkpoint} {merged_model_name} --device=cuda --enable_offloading
Конвертируем модель в GGUF формат
Перед конвертацией нам нужно сохранить Tokenizer в любую папку. Tokenizer берём той же самой модели Saiga
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("IlyaGusev/saiga2_7b_lora", use_fast=False)
tokenizer.save_pretrained('/kaggle/working/tmp/checkpoint-40/')
Далее переходим в папку llama.cpp и задаём необходимые параметры для конвертирования модели.
model_dir - путь до «склеенной» модели
checkpoint (можно было назвать tokenizer_path) - путь до сохранённого токенизатора
output_model - куда сохраняем нашу модель
ctx - количество tokens на входном слое (лучше оставить такое же значение как и у оригинальной модели)
Пример параметров
model_dir = '/kaggle/tmp/merged_test_model.pt'
checkpoint = "/kaggle/working/tmp/checkpoint-40"
output_model = "/kaggle/tmp/model-f16.gguf"
Далее запускаем скрипт convert.py
!python convert.py {model_dir} --vocab-dir {checkpoint} --outfile {output_model} --outtype f16 --ctx 4096
Квантование модели до 4 бит
Для начала вызываем !make quantize
Далее также задаём параметры
model_gguf - наша 16 битная модель gguf
quant_model - куда сохраняем модель + название
quantization_type - тип квантования (используем q4_0)
После объявления параметров запускаем скрипт
! ./quantize {model_gguf} {quant_model} {quantization_type}
Проверяем нашу модель
Скрипт для фикса библиотеки, чтобы запустить модель GGUF на GPU
%cd ~
!git clone --recursive https://github.com/ggerganov/llama.cpp.git
%cd llama.cpp
!make LLAMA_CUBLAS=1 -j libllama.so
# HACK: Use custom compiled libllama.so
%cp ~/llama.cpp/libllama.so /opt/conda/lib/python3.10/site-packages/llama_cpp/libllama.so
Скрипт для запуска модели
from llama_cpp import Llama
from tqdm import tqdm
SYSTEM_PROMPT = "Ты извлекаешь термины и определения из текста"
SYSTEM_TOKEN = 1788
USER_TOKEN = 1404
BOT_TOKEN = 9225
LINEBREAK_TOKEN = 13
top_k=40
top_p=0.5
temperature=0.01
repeat_penalty=1.1
ROLE_TOKENS = {
"user": USER_TOKEN,
"bot": BOT_TOKEN,
"system": SYSTEM_TOKEN
}
def get_message_tokens(model, role, content):
message_tokens = model.tokenize(content.encode("utf-8"))
message_tokens.insert(1, ROLE_TOKENS[role])
message_tokens.insert(2, LINEBREAK_TOKEN)
message_tokens.append(model.token_eos())
return message_tokens
def get_system_tokens(model):
system_message = {
"role": "system",
"content": SYSTEM_PROMPT
}
return get_message_tokens(model, **system_message)
def chat_saiga(message, model):
system_tokens = get_system_tokens(model)
tokens = system_tokens
# model.eval(tokens)
message_tokens = get_message_tokens(model=model, role="user", content=message)
role_tokens = [model.token_bos(), BOT_TOKEN, LINEBREAK_TOKEN]
tokens += message_tokens + role_tokens
# print(tokens)
# detokenize = model.detokenize(tokens)
# print(model.tokenize(full_prompt))
generator = model.generate(
tokens,
top_k = top_k,
top_p = top_p,
temp = temperature,
repeat_penalty = repeat_penalty,
reset = True
)
# print(len([token for token in generator]))
result_list = []
for token in generator:
token_str = model.detokenize([token]).decode("utf-8", errors="ignore")
tokens.append(token)
if token == model.token_eos():
break
print(token_str, end="", flush=True)
result_list.append(token_str)
return ''.join(result_list)
try:
del model
except:
pass
# model_path = '/kaggle/working/model-q4_0.gguf'
model_path = '/kaggle/working/model-q4_0.gguf'
n_ctx = 3096 #
model = Llama(
model_path = model_path,
n_ctx = n_ctx,
n_gpu_layers=-1
)
В итоге модель неплохо обучилась и выводит строку в нужном формате. Датасет для обучения был составлен с помощью GPT. Всего экземпляров данных было 28
train - 22 экземпляра
val - 6 экземпляров
Причина по которой создан данный туториал
Этот туториал появился, когда мы в AI-команде Инлайн работали над интеграцией Сайги с вызовом функций и для обращения к внешним API, чтобы LLM могла запрашивать внешнюю информацию для ответов на вопросы.
И мы хотели бы, чтобы каждый мог при желании сам попробовать дообучить и оценить свою модель LLM
Ссылки на полезные источники
Отдельно хочу поблагодарить за проделанную работу автора Saiga Илью Гусева и автора llama.cpp Георгия Герганова
ridos32
Круто, надо попробовать!