To Data & Beyond

To Data & Beyond

Share this post

To Data & Beyond
To Data & Beyond
Qwen 3 Mathematical Reasoning Fine Tuning with GRPO Technique #2

Qwen 3 Mathematical Reasoning Fine Tuning with GRPO Technique #2

Hands-on Tutorial on Reasoning Fine Tuning Qwen 3 with GRPO

Youssef Hosni's avatar
Youssef Hosni
May 24, 2025
∙ Paid
7

Share this post

To Data & Beyond
To Data & Beyond
Qwen 3 Mathematical Reasoning Fine Tuning with GRPO Technique #2
2
Share

Get 50% off for 1 year

Enhancing the reasoning abilities of Large Language Models (LLMs) is important for their application in complex tasks. This technical guide initiates a practical walkthrough for fine-tuning the Qwen-3 model specifically for reasoning, using the GRPO (General Reinforcement Pretraining Optimization) method.

The first part covered the foundational steps required before commencing the fine-tuning loop. It provided an introduction to the GRPO algorithm, details the setup of the necessary computational environment, outlines the procedures for loading the Qwen 3 base model and tokenizer, and describes the essential steps for acquiring and preparing the target dataset.

In this second part, we complete these stages by first defining the reward function that we will use to train the model. Then we fine-tune the model and test it after fine-tuning, and finally, we save it locally and on Hugging Face Hub.

Table of Contents:

  1. Introduction to GRPO [Part 1]

  2. Setting Up the Working Environment [Part 1]

  3. Loading the Model & Tokenizer [Part 1]

  4. Loading & Preprocessing the Dataset [Part 1]

  5. Define Reward Function [Part 2]

  6. Qwen 3 Reasoning Fine Tuning [Part 2]

  7. Testing the Fine-Tuned Model [Part 2]

  8. Saving the Fine-Tuned Model [Part 2]


My New E-Book: LLM Roadmap from Beginner to Advanced Level

Youssef Hosni
·
June 18, 2024
My New E-Book: LLM Roadmap from Beginner to Advanced Level

I am pleased to announce that I have published my new ebook LLM Roadmap from Beginner to Advanced Level. This ebook will provide all the resources you need to start your journey towards mastering LLMs.

Read full story

1. Define GRPO Reward Functions

We will be using Hugging Face’s Open-R1 Math dataset. You can also utilize OpenAI’s famous GSM8K dataset.

from datasets import load_dataset
dataset = load_dataset("open-r1/DAPO-Math-17k-Processed", "en", split = "train")
dataset

Dataset({
features: [‘prompt’, ‘solution’, ‘data_source’, ‘source_prompt’, ‘ability’, ‘reward_model’, ‘extra_info’],
num_rows: 14116
})

Let’s look at the first row:

dataset[0]["prompt"]

In triangle $ABC$, $\\sin \\angle A = \\frac{4}{5}$ and $\\angle A < 90^\\circ$. Let $D$ be a point outside triangle $ABC$ such that $\\angle BAD = \\angle DAC$ and $\\angle BDC = 90^\\circ$. Suppose that $AD = 1$ and that $\\frac{BD}{CD} = \\frac{3}{2}$. If $AB + AC$ can be expressed in the form $\\frac{a\\sqrt{b}}{c}$ where $a, b, c$ are pairwise relatively prime integers, find $a + b + c$.

dataset[0]["solution"]

34

In the GSM8K dataset, we notice all answers like about have a ####, so we extract it. But for the Open R1 dataset, we can skip the below.

def extract_hash_answer(text):
    # if "####" not in text: return None
    # return text.split("####")[1].strip()
    return text
extract_hash_answer(dataset[0]["solution"])

34

Let’s map the dataset as we have done before in the first article, and observe the first row:

dataset = dataset.map(lambda x: {
    "prompt" : [
        {"role": "system", "content": system_prompt},
        {"role": "user",   "content": x["prompt"]},
    ],
    "answer": extract_hash_answer(x["solution"]),
})
dataset[0]

{‘prompt’: [{‘content’: ‘You are given a problem.\nThink about the problem and provide your working out.\nPlace it between <start_working_out> and <end_working_out>.\nThen, provide your solution between <SOLUTION></SOLUTION>’,
‘role’: ‘system’},
{‘content’: ‘In triangle $ABC$, $\\sin \\angle A = \\frac{4}{5}$ and $\\angle A < 90^\\circ$. Let $D$ be a point outside triangle $ABC$ such that $\\angle BAD = \\angle DAC$ and $\\angle BDC = 90^\\circ$. Suppose that $AD = 1$ and that $\\frac{BD}{CD} = \\frac{3}{2}$. If $AB + AC$ can be expressed in the form $\\frac{a\\sqrt{b}}{c}$ where $a, b, c$ are pairwise relatively prime integers, find $a + b + c$.’,
‘role’: ‘user’}],
‘solution’: ‘34’,
‘data_source’: ‘math_dapo’,
‘source_prompt’: [{‘content’: ‘Solve the following math problem step by step. The last line of your response should be of the form Answer: $Answer (without quotes) where $Answer is the answer to the problem.\n\nIn triangle $ABC$, $\\sin \\angle A = \\frac{4}{5}$ and $\\angle A < 90^\\circ$. Let $D$ be a point outside triangle $ABC$ such that $\\angle BAD = \\angle DAC$ and $\\angle BDC = 90^\\circ$. Suppose that $AD = 1$ and that $\\frac{BD}{CD} = \\frac{3}{2}$. If $AB + AC$ can be expressed in the form $\\frac{a\\sqrt{b}}{c}$ where $a, b, c$ are pairwise relatively prime integers, find $a + b + c$.\n\nRemember to put your answer on its own line after “Answer:”.’,
‘role’: ‘user’}],
‘ability’: ‘MATH’,
‘reward_model’: {‘ground_truth’: ‘34’, ‘style’: ‘rule-lighteval/MATH_v2’},
‘extra_info’: {‘index’: ‘9a9b6eb4-a1cb-49d1–8c1e-62eaf2f74079’},
‘answer’: ‘34’}

We create a regex format to match the reasoning sections and answers:

import re
# Add optional EOS token matching
solution_end_regex = r"</SOLUTION>[\s]{0,}" + \
    "(?:" + re.escape(tokenizer.eos_token) + ")?"
match_format = re.compile(
    rf"{reasoning_end}.*?"\
    rf"{solution_start}(.+?){solution_end_regex}"\
    rf"[\s]{{0,}}$",
    flags = re.MULTILINE | re.DOTALL
)
re.compile(r'<end_working_out>.*?<SOLUTION>(.+?)</SOLUTION>[\s]{0,}(?:<\|endoftext\|>)?[\s]{0,}$',
re.MULTILINE|re.DOTALL|re.UNICODE)
match_format.findall(
    "Let me think!<end_working_out>"\
    f"<SOLUTION>\n2\n</SOLUTION>",
)

[‘\n2\n’]

match_format.findall(
 "<start_working_out>Let me think!<end_working_out>"\
 f"<SOLUTION> 2 </SOLUTION>\n\n",
)

[‘ 2 ‘]

We now want to create a reward function to match the format exactly — we reward it with 3 points if it succeeds:

def match_format_exactly(completions, **kwargs):
    scores = []
    for completion in completions:
        score = 0
        response = completion[0]["content"]
        # Match if format is seen exactly!
        if match_format.search(response) is not None: score += 3.0
        scores.append(score)
    return scores

If it fails, we want to reward the model if it at least follows the format partially, by counting each symbol:

def match_format_approximately(completions, **kwargs):
    scores = []
    for completion in completions:
        score = 0
        response = completion[0]["content"]
        # Count how many keywords are seen - we penalize if too many!
        # If we see 1, then plus some points!
        # No need to reward <start_working_out> since we always prepend it!
        # score += 0.5 if response.count(reasoning_start) == 1 else -1.0
        score += 0.5 if response.count(reasoning_end)   == 1 else -1.0
        score += 0.5 if response.count(solution_start)  == 1 else -1.0
        score += 0.5 if response.count(solution_end)    == 1 else -1.0
        scores.append(score)
    return scores

Finally, we want to extract the generated answer and reward or penalize it! We also reward it based on how close the answer is to the true one via ratios:

def check_answer(prompts, completions, answer, **kwargs):
    question = prompts[0][-1]["content"]
    responses = [completion[0]["content"] for completion in completions]
    extracted_responses = [
        guess.group(1)
        if (guess := match_format.search(r)) is not None else None \
        for r in responses
    ]
    scores = []
    for guess, true_answer in zip(extracted_responses, answer):
        score = 0
        if guess is None:
            scores.append(-2.0)
            continue
        # Correct answer gets 5 points!
        if guess == true_answer:
            score += 5.0
        # Match if spaces are seen, but less reward
        elif guess.strip() == true_answer.strip():
            score += 3.5
        else:
            # We also reward it if the answer is close via ratios!
            # Ie if the answer is within some range, reward it!
            try:
                ratio = float(guess) / float(true_answer)
                if   ratio >= 0.9 and ratio <= 1.1: score += 2.0
                elif ratio >= 0.8 and ratio <= 1.2: score += 1.5
                else: score -= 2.5 # Penalize wrong answers
            except:
                score -= 4.5 # Penalize
        scores.append(score)
    return scores

Also, sometimes it might not be 1 number as the answer, but like a sentence, for example, “The solution is $20” -> we extract 20. We also remove possible commas, for example,e as in 123,456

match_numbers = re.compile(
    solution_start + r".*?[\s]{0,}([-]?[\d\.\,]{1,})",
    flags = re.MULTILINE | re.DOTALL
)
print(match_numbers.findall("<SOLUTION>  0.34  </SOLUTION>"))
print(match_numbers.findall("<SOLUTION>  123,456  </SOLUTION>"))
print(match_numbers.findall("<SOLUTION>  -0.234  </SOLUTION>"))
print(match_numbers.findall("<SOLUTION>17</SOLUTION>"))

[‘0.34’]
[‘123,456’]
[‘-0.234’]
[‘17’]

We now prepare our main function, which will print out the generated responses and the true answer, along with another reward function that converts text to float via `float` and sees if it’s the same.

global PRINTED_TIMES
PRINTED_TIMES = 0
global PRINT_EVERY_STEPS
PRINT_EVERY_STEPS = 5

def check_numbers(prompts, completions, answer, **kwargs):
    question = prompts[0][-1]["content"]
    responses = [completion[0]["content"] for completion in completions]
    extracted_responses = [
        guess.group(1)
        if (guess := match_numbers.search(r)) is not None else None \
        for r in responses
    ]
    scores = []
    # Print only every few steps
    global PRINTED_TIMES
    global PRINT_EVERY_STEPS
    if PRINTED_TIMES % PRINT_EVERY_STEPS == 0:
        print(
            '*'*20 + f"Question:\n{question}", f"\nAnswer:\n{answer[0]}", f"\nResponse:\n{responses[0]}", f"\nExtracted:\n{extracted_responses[0]}"
        )
    PRINTED_TIMES += 1
    for guess, true_answer in zip(extracted_responses, answer):
        if guess is None:
            scores.append(-2.5)
            continue
        # Convert to numbers
        try:
            true_answer = float(true_answer.strip())
            # Remove commas like in 123,456
            guess       = float(guess.strip().replace(",", ""))
            scores.append(3.5 if guess == true_answer else -1.5)
        except:
            scores.append(0)
            continue
    return scores

We will get the top 90% prompt length so we don’t accidentally truncate them! We’ll remove the top 10% long prompts.

tokenized = dataset.map(
    lambda x: {"tokens" : tokenizer.apply_chat_template(x["prompt"], add_generation_prompt = True, tokenize = True)},
    batched = True,
)
print(tokenizer.decode(tokenized[0]["tokens"]))
tokenized = tokenized.map(lambda x: {"L" : len(x["tokens"])})

import numpy as np
maximum_length = int(np.quantile(tokenized["L"], 0.9))
print("Max Length = ", maximum_length)

# Filter only samples smaller than 90% max length
dataset = dataset.select(np.where(np.array(tokenized["L"]) <= maximum_length)[0])
del tokenized

You are given a problem. Think about the problem and provide your working out. Place it between <start_working_out> and <end_working_out>. Then, provide your solution between <SOLUTION></SOLUTION><|endoftext|>In triangle $ABC$, $\sin \angle A = \frac{4}{5}$ and $\angle A < 90^\circ$. Let $D$ be a point outside triangle $ABC$ such that $\angle BAD = \angle DAC$ and $\angle BDC = 90^\circ$. Suppose that $AD = 1$ and that $\frac{BD}{CD} = \frac{3}{2}$. If $AB + AC$ can be expressed in the form $\frac{a\sqrt{b}}{c}$ where $a, b, c$ are pairwise relatively prime integers, find $a + b + c$.<start_working_out> Max Length = 201


2. Qwen 3 Reasoning Fine-Tuning with GRPO

Now we are ready to set up the GRPO Trainer and all configurations to start fine-tuning the model.

This post is for paid subscribers

Already a paid subscriber? Sign in
© 2025 Youssef Hosni
Privacy ∙ Terms ∙ Collection notice
Start writingGet the app
Substack is the home for great culture

Share