Този уикенд реших да направя кратък тест на техниката Group Relative Policy Optimization (GRPO), въведена в DeepSeekMath: Pushing the Limits of Mathematical Reasoning in Open Language Models.
След пускането на GRPO класа в trl и фантастичния проект Unsloth.AI има много онлайн материали и статии за обучение на reasoning модели на английски. Но аз реших да направя нещо малко по-различно, а именно да обуча модел да „разсъждава“ на друг език – в този случай на моя роден език – българския.
Поради ограничения период от време, данни за обучение и ресурси, това ще бъде по-скоро доказателство за концепцията, но исках да потвърдя подхода. Надявам се, че може да бъде полезен за някой друг, който иска да създаде неанглоезичен модел за „логическо мислене“.
В тази статия ще опиша процеса на обучение на LLMBG-Llama-3.1-8B-BG-Reasoning-v0.1 – доколкото ми е известно, първият публично достъпен модел, обучен да „мисли“ на български език. Ще разгледам данните, подхода за обучение и резултатите.
Основният акцент: моделът започва да „мисли“ на български език само след 5 часа обучение на GPU сървър L40S. Въпреки че използвах български език, този подход може да бъде адаптиран за обучение на модели за разсъждение на всеки език.
Оригиналната статия на английски език може да бъде открита тук: https://unfoldai.com/reasoning-in-a-non-english-language/
Въведение
В началните изречения поставих думата „мисли“ в кавички. Същото би могло да се направи и за „логическо мислене“, „разсъждение“, тъй като има фундаментални разлики в начина, по който хората и моделите мислят, особено по отношение на това дали моделите могат наистина да мислят. Това обаче е отделна тема. В тази статия, оттук нататък ще използвам термините „разсъждение“ и „мислене“ без кавички, имайки предвид всичките условнисти на тези понятия.
Пускането на DeepSeek-R1 предизвика значително вълнение и напредък в областта на логическото мислене моделите (reasoning). Ако тази концепцията е нова за вас, препоръчвам ви да прочетете тази статия на Sebastian Raschka. В нея се разглежда въпросът дали тези модели са решение за всички проблеми.
💡Накратко: това не е най-добрият подход за задачи с ограничени ресурси или прости задачи, където стандартните модели могат да се представят по-добре.
По-долу ще намерите кратко сравнение на английски език, в което се подчертават силните страни на моделите за логическо мислене.

Основни предимства и ограничения на моделите за логическо мислене. Източник: https://sebastianraschka.com/blog/2025/understanding-reasoning-llms.html
DeepSeek-R1 е основният двигател на тази отворена дискусия за моделите за разсъждение (reasoning) и начина на тяхното обучение. Моделът обаче разсъждава основно на английски и китайски език. Ето защо целта на тази статия е да покаже как да се изразяват последователно разсъждения на друг език, като се спазва последователно синтаксисът на веригата на мисълта (Chain of Thoughts – CoT).
Ако се интересувате да научите повече за тези концепции, бих препоръчал този списък със статии, който открих в изданието на Андрий Бурков „Изкуствен интелект“ № 261 (което също горещо препоръчвам).
А сега нека започнем с действителния процес.
Базовия модел
Изборът на базов модел е много важна стъпка, тъй като ако базата не е предварително обучена на определен език, ще бъде невъзможно да се генерира смислен текст на него. Нуждаех се от модел, който да мога да обуча в продължение на 4-5 часа върху ~2 хил. извадки (повече подробности за набора от данни ще последват по-късно в статията) на графичен процесор с 48 GB VRAM. По-големи модели биха изисквали по-мощни машини.
Това ме накара да избера Llama 3.1 8B. Той е многоезичен и чрез бърз експеримент установих, че разбира български език на достатъчно добро ниво. Въпреки че има забележими разлики при сравнението ѝ с други модели с подобен размер.
Например Qwen 2.5 може да се представи по-добре за други европейски езици. Друг вариант, който обмислих, е Gemma 2 9B; всъщност сегашните най-съвременни български модели са базирани на Gemma.
В крайна сметка обаче реших да избера Llama.
Обучение
Обучението включваше подробности от статията в блога от Unsloth.AI, коригиране на някои параметри и използване на подходящ набор от данни. Обучих модела на Lightning.AI инфраструктура, но за по-големи задачи предпочитам да използвам GPU инстанции от OVH, главно поради цената.
За набора от данни използвах машинно преведен набор от данни GSM8k на български език от тук. Институтът INSAIT е публикувал публично само тестовия набор, който използвах за бързо валидиране на моя подход.
Има и пълна версия тук petkopetkov/gsm8k-bg, но отговорите и техните оригинални разделители (с ####
) са премахнати, което би направило обработката от моя страна по-объркана и некоректна.
Целта беше да се идентифицира набор от данни, който съдържа фиксирани, добре познати отговори, позволяващи на езиковия модел да се справи с аспекта на разсъжденията, като предостави допълнения. Много други набори от данни биха могли да бъдат модифицирани за тази цел, а също така е сравнително лесно да се създаде синтетичен набор от данни, като се използва по-голям модел.
Линковете с процеса на обучение: Stage 1 и Stage 2.
Те са твърде отделни, защото прекъснах процеса на 200-ата стъпка и след това започнах отново. Исках да създам допълнителна тетрадка и да се уверя, че ще запазя данните от предишното регистриране.
Тук можете да намерите пълния дневник на обучението, включително Training loss, Reward, Reward std, Completion length, и Kullback-Leibler.
Алтернативно, това хранилище съдържа всички тетрадки и дневници за обучение на едно място: https://github.com/s-emanuilov/LLMBG-Llama-3.1-8B-BG-Reasoning-v0.1
За процеса в повече подробности. Първата стъпка включва инсталиране на основните библиотеки:
!pip install unsloth vllm
!pip install --upgrade pillow
!pip install diffusers
# Temporarily install a specific TRL nightly version
!pip install git+https://github.com/huggingface/trl.git@e95f9fb74a3c3647b86f251b7e230ec51c64b72b
Важно е да се отбележи, че TRL е инсталиран от определена версия, тъй като GRPO е все още нов алгоритъм. След това:
from unsloth import FastLanguageModel, PatchFastRL
PatchFastRL("GRPO", FastLanguageModel)
Където GRPO кръпката се прилага към класа FastLanguageModel
, за да може Unsloth да работи с GRPO. След това трябва да изберем базовия модел, който в този случай е meta-llama/meta-Llama-3.1-8B-Instruct.
Избрах да задам max_seq_length
на 1024, за да осигуря повече място за мислене, и lora_rank
на 64, за да дам приоритет на качеството на изхода пред скоростта на генериране.
from unsloth import is_bfloat16_supported
import torch
max_seq_length = 1024
lora_rank = 64
model, tokenizer = FastLanguageModel.from_pretrained(
model_name = "meta-llama/meta-Llama-3.1-8B-Instruct",
max_seq_length = max_seq_length,
load_in_4bit = True,
fast_inference = True,
max_lora_rank = lora_rank,
gpu_memory_utilization = 0.65,
)
model = FastLanguageModel.get_peft_model(
model,
r = lora_rank,
target_modules = [
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj",
],
lora_alpha = lora_rank,
use_gradient_checkpointing = "unsloth",
random_state = 3407,
)
Настроих използването на паметта на графичния процесор на 0.65, за да използвам графичния процесор на определено ниво. По-високото съотношение води до грешки с паметта (OOM), докато по-ниското не използва напълно наличните ресурси.
За градиентната контролна точка избрах опцията „unsloth“, която е нов алгоритъм, позволяващ фина настройка на езикови модели с дълги контекстни прозорци. Можете да намерите повече информация тук.
Важен аспект на този експеримент са функциите за възнаграждение (rewards). Съсредоточих се предимно върху функции, които проверяват и възнаграждават структурата на форматирането на изхода.
Въпреки това смятам, че има значително поле за оптимизация в тази област. Ще обсъдя потенциални допълнителни функции за възнаграждение в раздела за бъдещи подобрения. Засега нека разгледаме настоящия процес:
import re
from datasets import load_dataset, Dataset
# Load and prep dataset
SYSTEM_PROMPT = """
Respond in the following format:
<reasoning>
...
</reasoning>
<answer>
...
</answer>
"""
XML_COT_FORMAT = """\
<reasoning>
{reasoning}
</reasoning>
<answer>
{answer}
</answer>
"""
def extract_xml_answer(text: str) -> str:
answer = text.split("")[-1]
answer = answer.split("")[0]
return answer.strip()
def extract_hash_answer(text: str) -> str | None:
if "####" not in text:
return None
return text.split("####")[1].strip()
def get_gsm8k_questions(split = "test") -> Dataset:
data = load_dataset('INSAIT-Institute/GSM8k-bgeval', 'default')[split] # type: ignore
data = data.map(lambda x: { # type: ignore
'prompt': [
{'role': 'system', 'content': SYSTEM_PROMPT},
{'role': 'user', 'content': x['question']}
],
'answer': extract_hash_answer(x['answer'])
}) # type: ignore
return data # type: ignore
dataset = get_gsm8k_questions()
# Reward functions
def correctness_reward_func(prompts, completions, answer, **kwargs) -> list[float]:
responses = [completion[0]['content'] for completion in completions]
q = prompts[0][-1]['content']
extracted_responses = [extract_xml_answer(r) for r in responses]
print('-'*20, f"Question:\n{q}", f"\nAnswer:\n{answer[0]}", f"\nResponse:\n{responses[0]}", f"\nExtracted:\n{extracted_responses[0]}")
return [2.0 if r == a else 0.0 for r, a in zip(extracted_responses, answer)]
def int_reward_func(completions, **kwargs) -> list[float]:
responses = [completion[0]['content'] for completion in completions]
extracted_responses = [extract_xml_answer(r) for r in responses]
return [0.5 if r.isdigit() else 0.0 for r in extracted_responses]
def strict_format_reward_func(completions, **kwargs) -> list[float]:
"""Reward function that checks if the completion has a specific format."""
pattern = r"^\n.*?\n\n\n.*?\n\n$"
responses = [completion[0]["content"] for completion in completions]
matches = [re.match(pattern, r) for r in responses]
return [0.5 if match else 0.0 for match in matches]
def soft_format_reward_func(completions, **kwargs) -> list[float]:
"""Reward function that checks if the completion has a specific format."""
pattern = r".*?\s*.*?"
responses = [completion[0]["content"] for completion in completions]
matches = [re.match(pattern, r) for r in responses]
return [0.5 if match else 0.0 for match in matches]
def count_xml(text) -> float:
count = 0.0
if text.count("\n") == 1:
count += 0.125
if text.count("\n\n") == 1:
count += 0.125
if text.count("\n\n") == 1:
count += 0.125
count -= len(text.split("\n\n")[-1])*0.001
if text.count("\n") == 1:
count += 0.125
count -= (len(text.split("\n")[-1]) - 1)*0.001
return count
def xmlcount_reward_func(completions, **kwargs) -> list[float]:
contents = [completion[0]["content"] for completion in completions]
return [count_xml(c) for c in contents]
Накратко, те следват материалите от Unsloth.AI, като основно награждават правилен синтаксис на изхода. Но това не е непременно лошо, тъй като може да се счита за съгласуване на модела, за да отговаря в този специфичен формат. А по-нататък можем да награждаваме и други аспекти на генерирането. След това конфигурациите на GRPO:
from trl import GRPOConfig, GRPOTrainer
training_args = GRPOConfig(
use_vllm = True,
learning_rate = 5e-6,
adam_beta1 = 0.9,
adam_beta2 = 0.99,
weight_decay = 0.1,
warmup_ratio = 0.1,
lr_scheduler_type = "cosine",
optim = "paged_adamw_8bit",
logging_steps = 1,
bf16 = is_bfloat16_supported(),
fp16 = not is_bfloat16_supported(),
per_device_train_batch_size = 1,
gradient_accumulation_steps = 1,
num_generations = 6,
max_prompt_length = 256,
max_completion_length = 512,
num_train_epochs = 1,
# max_steps = 250,
save_steps = 100,
max_grad_norm = 0.1,
report_to = "none",
output_dir = "outputs",
)
Изпълних пълна епоха на обучение с num_train_epochs
, зададена на 1, и увеличих дължината на завършването до максимум 512 токена, като зададох max_completion_length
.
Скоростта на обучението беше конфигурирана на 5e-6, а за контролните точки зададох save_steps
на 100 .
Другите параметри бяха много подобни на стандартните конфигурации на Unsloth.
След това дойде ред на обучителя и на самия процес на обучение. Ключов момент, който трябва да се отбележи, е включването на функциите за възнаграждение, където можете да добавите свои собствени:
trainer = GRPOTrainer(
model = model,
processing_class = tokenizer,
reward_funcs = [
xmlcount_reward_func,
soft_format_reward_func,
strict_format_reward_func,
int_reward_func,
correctness_reward_func,
],
args = training_args,
train_dataset = dataset,
)
trainer.train()
Резултати
В първите няколко стъпки можем да видим очевидно грешни отговори. Пример:
</отговор>
Extracted:
<причината>
На първата улица има 5 деца.
На втората улица към тези 5 деца се присъединят още 5 деца (1 на повишение), така че има 5 + 5 = 10 деца.
На третата улица към тези 10 деца се присъединяват още 10 деца (2 на повишение), така че сега има 10 + 10 = 20 деца.
На втората улица първите 5 деца се отказват, така че сега има 20 - 5 = 15 деца, които следват камиона.
</причина>
<отговор>
15
</отговор>
Ясно е, че не се спазва синтаксисът на CoT. Въпреки че изискваме използването на български тагове, в този текст са използвани уникални такива, които не са правилно затворени например.
Този проблем обаче се подобрява с обучението.
По-долу ще намерите необработена диаграма на възнагражденията на дадена стъпка.
Не съм групирал данните по стъпки, което ви позволява да видите как те се колебаят на всеки етап. Въпреки това линията на тенденцията показва ясно възходящо движение: възнагражденията се увеличават и моделът се обучава.

Награди за GRPO на определена стъпка
Въз основа на това могат да се направят няколко заключения:
- Около стъпка 190 се наблюдава ясен пробив – резултатите от възнагражденията на модела скачат значително и стават по-постоянни;
- Функциите за възнаграждение са далеч от оптималните; въпреки че тенденцията е положителна, няма много възможности за по-нататъшно обучение. Максималното възнаграждение е 3 по проект, а моделът се подобрява с всяка стъпка, повишавайки ефективността си, докато не достигнем тавана.
Резултатите в последните стъпки изглеждат по-добре:
Question: Лори печели $10 на час. Карън печели два пъти повече от Лори. Колко печели Карън за два дни, ако работи по 3 часа на ден?
Answer:
120
Response:
<reasoning>
Ако Лори печели 10 долара на час, а Карън печели два пъти повече, Карън печели 10 * 2 = 20 долара на час. Ако Карън работи 3 часа на ден, в два дни тя печели 3 * 20 * 2 = 120 долара.
</reasoning>
<answer>
120
</answer>
Extracted (from the dataset):
120
=====
Question: Тоби чете книга с дължина 45 страници. Средно 200 думи на страница. Тоби може да чете със скорост 300 думи в минута. Той трябва да бъде на летището след 60 минути и планира да тръгне веднага щом завърши книгата. Отнема 10 минути, за да стигнете до летището. Колко минути ще подрани Тоби?
Answer:
20
Response:
<reasoning>
Дължината на книгата е 45 страници * 200 думи на страница = 9000 думи. Тоби може да чете 9000 думи / 300 думи в минута = 30 минути. Трябва да се добавят 10 минути за пътуването до летището, така че в общият период ще бъде 30 + 10 = 40 минути.
</reasoning>
<answer>
40
</answer>
Extracted (from the dataset):
40
=====
Question: Ян има три пъти повече домашни любимци от Марсия. Марсия има с два повече домашни любимци от Синди. Ако Синди има четири домашни любимеца, колко общо домашни любимци имат тримата?
Answer:
28
Response:
<reasoning>
Ако Синди има четири домашни любимци, а Марсия има два повече домакински любимци от Синди, то Марсия има 4 + 2 = 6 домашни любимци. Ако Ян има три пъти повече домашни любимци от Марсия, то Ян има 3 * 6 = 18 домашни любимци. Общо повече има 4 + 6 + 18 = 28 домашни любимци.
</reasoning>
<answer>
28
</answer>
Extracted (from the dataset):
28
Допълнителни подобрения
Тъй като това е проект за доказване на концепцията в рамките на уикенд (с недостатъчно данни), има много възможности за подобрения.
Първо, функциите за възнаграждение (rewards). Предишният пример илюстрира как функциите за възнаграждение могат да присвояват точки за структуриран изход. Въпреки това могат да се добавят и много други функции за бъдещо подреждане. Например бихме могли да реализираме функция, която възнаграждава генерирането на текст на кирилица, или такава, която осигурява награди за по-дълъг аргументиран текст. Възможностите са безкрайни, както ни напомня клишето.
Второ, данните. Обучението се базираше на данни от машинен превод с помощта на Google Translate API (източник), които не са пълни. За целите на мащабирането има много налични набори от данни с отворен код, които могат да се използват. Освен това смятам, че Google Translate API не е най-добрият вариант за тази цел. Постигнах много по-добро качество на превода, като използвах други методи, например DeepL, последвани от проверка от по-голям езиков модел. Генерирането на такива набори от данни обаче може да бъде скъпо, но за GRPO не се нуждаем от голям брой образци. Бихме могли да включим и синтетични данни; аз получих висококачествени набори от данни от сонета Клод 3.5.
Трето, базовият модел. Llama 3.1 е многоезичен модел, обучен върху голяма част от интернет, която включва различни езици. Забелязах обаче, че голяма част от генерирания текст не е напълно съгласуван с българския език. Понякога той бърка българския език с руския или английския. Този проблем потенциално би могъл да се реши чрез continual pre-training, подобно на метода, използван за най-съвременния български модел (BgGPT 1.0: Extending English-centric LLMs to Other Languages). Опитах се да направя това с LLMBG-Llama-3.1-8B-bnb-4bit, но данните ми са ограничени и не постигнах задоволителни резултати (засега). Изходът, характеризиращ се с разхвърляни символи и лошо построени изречения понякога, ми напомня за DeepSeek-R1-Zero, от оригиналния документ за DeepSeek R1.
И накрая, модифицирането на синтаксиса на CoT, така че да дава по-кратки текстови отговори вместо строги (и следователно проверими) отговори, също може да бъде добра посока. Функциите за възнаграждение биха могли да използват мерки за сходство (като например оценка на BERT, Edit разстояния и др.).
Този подход обаче може да доведе до по-бавен процес на обучение. Бърз пример:
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from nltk.translate.bleu_score import sentence_bleu
from difflib import SequenceMatcher
def text_similarity_reward_func(prompts, completions, answer, **kwargs) -> list[float]:
"""
Combines multiple text similarity metrics to evaluate Bulgarian answers
"""
responses = [extract_xml_answer(completion[0]['content']) for completion in completions]
def calculate_similarities(response: str, target: str) -> float:
# Convert to lowercase for better comparison
response = response.lower()
target = target.lower()
# Calculate similarity using multiple metrics
try:
# TF-IDF Cosine Similarity
vectorizer = TfidfVectorizer()
tfidf_matrix = vectorizer.fit_transform([response, target])
cosine_sim = cosine_similarity(tfidf_matrix[0:1], tfidf_matrix[1:2])[0][0]
# Sequence Matcher
sequence_sim = SequenceMatcher(None, response, target).ratio()
# BLEU Score for n-gram overlap
bleu_score = sentence_bleu([target.split()], response.split())
except:
return 0.0
# Combine metrics with weights
combined_score = (
0.4 * cosine_sim + # TF-IDF
0.4 * sequence_sim + # Sequence similarity
0.2 * bleu_score # BLEU score
)
return min(combined_score * 2.0, 2.0) # Scale to max 2.0
return [calculate_similarities(r, a) for r, a in zip(responses, answer)]
# Example usage
test_completions = [[{
"content": """
Магазинът продава ябълки по 2 лева.
Една ябълка струва 2 лева.
"""
}]]
test_answer = ["Цената на една ябълка е 2 лева."]
rewards = text_similarity_reward_func(None, test_completions, test_answer)
print(f"Similarity reward: {rewards[0]:.2f}")
Заключение
Създаването на модела беше интересно. Беше възможно за един уикенд, ползвайки напредъка от гиганти като DeepSeek, HuggingFace TRL, UnslothAI, Meta и Llama, оригиналния набор от данни на OpenAI и много други.
Резултатите не са перфектни, но бюджетът за обучение, изразходваното време и качеството на данните са пренебрежимо малки в сравнение с това, което инвестират настоящите доставчици (например BgGPT е обучен на 64 NVIDIA H100).
Експериментът показва, че обучението на основен модел за разсъждение на неанглоезичен език е осъществимо с ограничени ресурси. Макар че моите функции за възнаграждение се фокусираха върху формата и основната точност, има място за по-сложни подходи – като описаните от мен метрики за сходство на текста, които биха могли да оценят по-добре качеството на разсъжденията на всеки език.
Ключовият извод е, че не са ви необходими огромни изчислителни ресурси, за да започнете да експериментирате с модели за логическо мислене. С един графичен процесор, някои преведени данни и подходящи инструменти с отворен код можете да изградите основа за по-сложна работа. Техниката GRPO се оказа особено ефективна при научаването на модела да поддържа последователни модели на разсъждение, дори и на различен език.
Надявам се, че тази статия ще послужи като практическо ръководство за всеки, който иска да обучи модел за разсъждение на своя език. Кодът и подходът могат да бъдат адаптирани за други езици и макар че резултатите няма да съответстват на комерсиалните модели, те осигуряват солидна отправна точка за по-нататъшни изследвания и експерименти.