Introduction to Model Distillation: Efficient Knowledge Transfer for AI Applications - Part 3¶
So far we have:
- created synthetic data set (Using 1_generate_training_data.ipynb)
- fine tuned a custom model (Using 2_training.ipynb)
This notebook shows how to test the new model
Pre requisites¶
1 - Load dependencies¶
import os
from dotenv import load_dotenv
from typing import Sequence
from openai import Client
from datasets import load_dataset, Dataset, concatenate_datasets
from tqdm import tqdm
import pandas as pd
import json
import numpy as np
import time
import requests
import re
from dotenv import load_dotenv
load_dotenv()
# read model name from file
model_name_file = 'model_name.out'
if os.path.exists(model_name_file):
with open(model_name_file, 'r') as f:
MODEL_NAME = f.read().strip()
else:
# fallback to default model name if file does not exist
raise Exception (f"Model name file '{model_name_file}' not found. Please run the fine-tuning notebook (2_training.ipynb) first.")
3 - Initialize Client¶
The cell below creates the OpenAI-like Client to work with Nebius Token Factory and defines necessary variables.
DATASETS_CACHE_DIR = 'cache'
BASE_URL = "https://api.tokenfactory.nebius.com"
client = Client(
base_url=f'{BASE_URL}/v1',
api_key=os.getenv('NEBIUS_API_KEY')
)
4 - Test our model¶
Let's test our model on an example.
Original sentence: "Nebius Token Factory is a comprehensive platform designed to simplify the integration of AI capabilities into applications."
Modified sentence: "Nebius Token Factory is a comprehensive platform designed too simplify the integration of AI capabiltes into applcations."
The errors introduced are highlighted in bold.
First, generate the corrected version using our trained model and compare it with the original sentence.
system_prompt_fine_tuning = "Please correct the grammar in the user's text if necessary."
empty_reasoning_prefix = """
<think>
</think>
""".lstrip()
sample_text = "Nebius Token Factory is a comprehensive platform designed to simplify the integration of AI capabilities into applications."
text_with_errors = "Nebius Token Factory is comprehensive platform designed too simplify integration of AI capabiltes into applcations."
resp = client.chat.completions.create(
model=MODEL_NAME,
messages=[
{'role': 'system', 'content': system_prompt_fine_tuning},
{'role': 'user', 'content': text_with_errors}
],
temperature=0.7,
top_p=0.8,
max_tokens=40
)
model_generation = resp.choices[0].message.content.split('</think>')[-1].strip()
if sample_text == model_generation:
print('Model generation coincides with the original text!')
else:
print('Model generation differs from the original text:', model_generation)
Model generation coincides with the original text!
We can see that the generation coincides with the original text, which means our model did its job! Let's now evaluate the quality of our fine-tuned model and ensure it provides superior quality compared to a baseline model - Qwen3-14B - a larger model from the same family. Again, we can do this conveniently using Nebius Token Factory.
5 - Evaluate the model¶
Heads up: Running this part will cost ~\$3.4 (generations with the models cost ~ \$0.02). Changing the evaluation model to its non-reasoning version DeepSeek-V3 will reduce the cost of running the model to ~\$0.5, but can slightly decrease the reliability of evaluation.
The evaluation procedure in text generation tasks is not as straightforward as in classification tasks. This is because the task can have multiple correct outputs for the given input. For example, in our task, in the input text "The team leader which I spoke to yesterday, ...", the word "which" can be corrected to "who" and "whom", and both options are correct.
Therefore, the procedure of evaluation highly depends on the nature of the task. In some cases, for example, story generation, one would have to ask an LLM to verify every generated story against the input data. For other tasks like sentence paraphrasing, some generated paraphrases may coincide with the reference ones, removing the need to run LLM on these coinciding cases. Still, for almost any text generation task, some cases need to be evaluated either with a human's or an LLM's help. We can again leverage Nebius Token Factory to run the evaluator LLM.
We will take the test set of the JFLEG dataset[4], used for evaluation of grammatic error correction systems. Each instance is a sentence that contains 4 corrections from 4 language experts. Some corrections may coincide with the original sentence if the sentence is grammatically correct. The dataset contains spaces before the punctuation marks - we'll remove them to have the data in human-like format.
For our task, grammatic error correction, we can also leverage its peculiarities to simplify the evaluation and reduce the number of cases where the results depend on another LLM's verdict. We will use the following pipeline. After we generate corrections with the fine-tuned and baseline models, we will compare these corrections with the reference corrections. If a model generation coincides with at least one expert correction, we will consider it valid. If it doesn't, and the model generation introduces no edits to the original text, this means an error since all the experts suggested corrections. Finally, if none of the conditions above is met, we will ask a powerful LLM to assess whether the correction suggested by the model is correct or not.
Because the baseline model is not that large (14B parameters) and it was not trained to generate data in the desired format, it can sometimes fail to strictly follow the output format. Hence, we'll use extended instructions with few-shot learning examples to guide it to output the results in the desired format - the system prompt we used to distill Qwen/Qwen3-235B-A22B.
As already discussed, we will use both models in non-reasoning mode to simulate their real-world application where the result should be generated on the fly.
5.1 - Load the dataset.¶
eval_dataset = load_dataset('jhu-clsp/jfleg', cache_dir=DATASETS_CACHE_DIR)['test']
# Remove bad instances where no corrections are suggested or the sentence is empty
eval_dataset = eval_dataset.filter(lambda x: x['sentence'] != '' and not all(y == '' for y in x['corrections']))
print(eval_dataset[0])
eval_dataset
Generating validation split: 0%| | 0/755 [00:00<?, ? examples/s]
Generating test split: 0%| | 0/748 [00:00<?, ? examples/s]
Filter: 0%| | 0/748 [00:00<?, ? examples/s]
{'sentence': 'New and new technology has been introduced to the society .', 'corrections': ['New technology has been introduced to society .', 'New technology has been introduced into the society .', 'Newer and newer technology has been introduced into society .', 'Newer and newer technology has been introduced to the society .']}
Dataset({
features: ['sentence', 'corrections'],
num_rows: 747
})
5.2 - Preprocess it by removing spaces before punctuation marks.¶
def fix_spacing(text):
return re.sub(r'\s+([.,:;?!])', r'\1', text).strip()
eval_dataset = eval_dataset.map(lambda x: {"sentence": fix_spacing(x["sentence"])}, remove_columns="sentence")
eval_dataset = eval_dataset.map(lambda x: {"corrections": list(map(fix_spacing, x["corrections"]))}, remove_columns="corrections")
eval_dataset
Map: 0%| | 0/747 [00:00<?, ? examples/s]
Map: 0%| | 0/747 [00:00<?, ? examples/s]
Dataset({
features: ['sentence', 'corrections'],
num_rows: 747
})
6 - Create answers¶
Now that we have the data, let's generate results with both models. To diversify our tutorial, let's use normal synchronous generation here. We can parallelize it to accelerate the process.
We have two choices to enable non-reasoning mode:
- force the models to start generation with the empty reasoning pattern - this is how the authors of Qwen3 suggest doing
- add the "/no_think" token to the user's message
Since the "/no_think" token will anyway urge the model to start the generation with the empty reasoning pattern, let's use the first option. We'll need to adopt two additional arguments for the completion endpoint of Token Factory - continue_final_message and add_generation_prompt. The first argument formats the chat so that the final message in the chat is open-ended, without any EOS tokens. This enables the model to continue the final message rather than starting a new one. When it is set to True, the second argument must be set to False.
%%time
from concurrent.futures import ThreadPoolExecutor, as_completed
def _call_llm(input_text: str, system_prompt: str, **call_kwargs):
return client.chat.completions.create(
messages=[
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': input_text},
{'role': 'assistant', 'content': empty_reasoning_prefix}
],
extra_body={"continue_final_message": True, "add_generation_prompt": False},
**call_kwargs
).choices[0].message.content
def generate(
input_text: str,
model: str = MODEL_NAME,
system_prompt: str = system_prompt_fine_tuning,
max_tokens: int = 128, # setting to 128 as default to make sure our generation won't terminate on long sentences
top_p: float = 0.8, # as suggested by Qwen3 authors
temperature: float = 0.7, # as suggested by Qwen3 authors
) -> str:
# Use try-except construction in case we encounter an error
output = None
while output is None:
try:
return _call_llm(
input_text=input_text,
system_prompt=system_prompt,
model=model,
max_tokens=max_tokens,
top_p=top_p
)
except Exception as e:
print(f'Error for input text {input_text}:\n{e}\nSleeping for 3 seconds and trying again...')
time.sleep(3)
continue
ft_model_generations = [None] * len(eval_dataset)
# Be careful with quota limits - you may need to reduce it
max_workers = 12
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {executor.submit(generate, inst['sentence'], MODEL_NAME, system_prompt_fine_tuning): idx for idx, inst in enumerate(eval_dataset)}
for future in tqdm(as_completed(futures), total=len(eval_dataset)):
idx = futures[future]
ft_model_generations[idx] = future.result()
ft_model_generations[:2]
100%|██████████| 747/747 [02:01<00:00, 6.17it/s]
['<think>\nOkay, let\'s see. The user wrote: "New and new technology has been introduced to the society." The user wants me to correct the grammar if necessary.\n\nFirst, I need to check if there are any grammatical errors. The phrase "New and new technology" seems repetitive. The word "new" is used twice. That\'s probably a redundancy. So maybe it should be "New technology" instead of "New and new technology." \n\nNext, the verb "has been introduced" is correct, but the subject "New and new technology" is singular. Wait, but "technology" is uncountable,', '<think>\nOkay, the user provided a sentence and wants me to correct any grammar issues. Let me read through it carefully.\n\nThe original sentence: "One possible outcome is that an environmentally-induced reduction in motorization levels in the richer countries will outweigh any rise in motorization levels in the poorer countries."\n\nFirst, I need to check for grammatical errors. Let\'s start with the structure. The sentence is complex, so I should ensure that the clauses are correctly connected. The main clause is "One possible outcome is that..." followed by a dependent clause. The dependent clause uses "that" correctly.\n\nLooking at the words: "environmentally']
Let's repeat the procedure for the baseline model. We need to change the prompt (as discussed earlier) and reduce the number of parallelization workers in order not to exceed the quota.
%%time
system_prompt_distillation = """
Act as an experienced English proofreader. Please check the grammar of the user's text. If the text contains errors or misprints, print the corrected text. Otherwise, print the text as it is, otherwise. Check only the grammar of the text. Don't print anything else.
Examples for few-shot learning:
Example 1 (the text contains errors):
User: In fact who let me know abut this program was him.
Assistant: In fact, he was the one who let me know about this program.
Example 2 (the text does not contain errors):
User: On the other hand, it's very efficient computationally as it only requires one forward pass through the model per example.
Assistant: On the other hand, it's very efficient computationally as it only requires one forward pass through the model per example.
""".strip()
bs_model_generations = [None] * len(eval_dataset)
# Reduce the parallelization to prevent rate limit errors
max_workers = 16
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(
generate,
inst['sentence'],
'Qwen/Qwen3-30B-A3B-Instruct-2507',
system_prompt_distillation,
): idx
for idx, inst in enumerate(eval_dataset)
}
for future in tqdm(as_completed(futures), total=len(eval_dataset)):
idx = futures[future]
bs_model_generations[idx] = future.result()
bs_model_generations[:2]
100%|██████████| 747/747 [01:36<00:00, 7.77it/s]
CPU times: user 5.04 s, sys: 318 ms, total: 5.36 s Wall time: 1min 36s
['<think>\nOkay, let\'s take a look at the user\'s sentence: "New and new technology has been introduced to the society." The user wants me to check the grammar. \n\nFirst, I notice "New and new technology." That repetition of "new" seems off. Maybe they meant "New and emerging technology" or "New and innovative technology." But the user might just be emphasizing the novelty, so maybe "new" is intentional. However, in standard grammar, repeating the same adjective isn\'t typically correct unless for stylistic reasons. But the user\'s example shows they correct repetition, so I should check that.\n\nNext,', '<think>\nOkay, let me take a look at the user\'s sentence. The original text is: "One possible outcome is that an environmentally-induced reduction in motorization levels in the richer countries will outweigh any rise in motorization levels in the poorer countries."\n\nFirst, I need to check for grammar errors. The sentence structure seems correct. The main clause is "One possible outcome is that..." followed by a subordinate clause. The use of "will outweigh" is appropriate here because it\'s a future prediction. \n\nLooking at the adjectives: "richer countries" and "poorer countries" are correctly comparative forms. The phrase "environment']
7 - Which answers need LLM verification?¶
Now let's determine cases where an LLM is required to evaluate the generation of the model. As discussed, this means cases where the model generation differs from all corrections and the original sentence.
ft_results = {}
bs_results = {}
ft_ids_require_verification = []
bs_ids_require_verification = []
coinciding_ids = set()
for i in range(len(eval_dataset)):
row = eval_dataset[i]
sentence = row['sentence']
corrections = row['corrections']
# If generation is in corrections, it is considered valid
if ft_model_generations[i] in corrections:
ft_results[i] = 1
# Else, if generation coincides with the original sentence, it is considered
# invalid because the original sentence is not among the corrections
# (otherwise it would meet the previous condition)
elif ft_model_generations[i] == sentence:
ft_results[i] = 0
# Otherwise need to call an LLM for verification
else:
ft_ids_require_verification.append(i)
# Same for the baseline model
if bs_model_generations[i] in corrections:
bs_results[i] = 1
elif bs_model_generations[i] == sentence:
bs_results[i] = 0
# If the generation doesn't fall under the two conditions and coincides with the fine-tuned model generation,
# we don't need to duplicate verification for the baseline model
elif bs_model_generations[i] == ft_model_generations[i]:
coinciding_ids.add(i)
else:
bs_ids_require_verification.append(i)
len(ft_ids_require_verification), len(bs_ids_require_verification)
CPU times: user 37.5 ms, sys: 2.03 ms, total: 39.5 ms Wall time: 38.5 ms
(747, 747)
8- Using LLM for verification¶
Verification of generations is not a trivial task and requires some reasoning from the model. Furthermore, to avoid correlation of scores with the generations,
let's use another powerful reasoning LLMs here - DeepSeek-R1. We will use its 'fast' checkpoint to speed up the process.
Our fine-tuned model was trained on data produced by Qwen/Qwen3-235B-A22B, while the baseline model also comes from the Qwen3 family. Using Qwen/Qwen3-235B-A22B for evaluation in this scenario may lead to overrated scores since this model's generations correlate with generations of the two models we are evaluating.
%%time
evaluation_model = 'deepseek-ai/DeepSeek-R1-0528'
system_prompt_evaluate = """
Act as an experienced grammar checker.
You will be provided with:
1. A sentece that may contain errors
2. Its 4 corrections suggested by 4 language experts (corrections may coincide with the sentence if the expert believes the sentence is grammatically correct)
3. Its correction suggested by the proofreader
Please evaluate whether the correction written by a proofreader is valid (1) or not (0). \
The correction is considered invalid if it omits editing any of the errors or rectifies indeed gramatically correct pieces of text. \
Otherwise, it is considered valid.
Again, please output:
- "1", if the editing suggested by the proofreader is correct
- "0", if the editing suggested by the proofreader is incorrect
Only output the number ("0" / "1") and nothing else.
""".strip()
user_prompt_evaluate_template = """
Sentence:
{sentence}
Corrections:
{corrections}
Proofreader's correction:
{generation}
""".strip()
def evaluate_text(
row: dict[str, str | list[str]], generation: str, system_prompt: str = system_prompt_evaluate
) -> str:
formatted_corrections = '\n'.join(f"{i}. {correction}" for i, correction in enumerate(row['corrections']))
user_prompt = str(user_prompt_evaluate_template).format(
sentence=row['sentence'],
corrections=formatted_corrections,
generation=generation
)
answer_with_reasoning = client.chat.completions.create(
model=evaluation_model,
messages=[
{'role': 'system', 'content': system_prompt_evaluate},
{'role': 'user', 'content': user_prompt},
],
max_tokens=4096, # to make sure the model finishes the reasoning and outputs an answer
top_p=0.01, # to make the results as deterministic as possible
).choices[0].message.content
answer = answer_with_reasoning.split('</think>')[-1].strip()
return convert_verdict_to_number(answer)
def convert_verdict_to_number(verdict: str) -> int:
if verdict.isdigit():
return int(verdict)
# To make sure we don't overrate the performance of the model, if DeepSeek-R1 outputs smth else
# than a number, consider the editing of the model invalid
print(f"Cannot parse the answer {verdict}. Replacing with 0.")
return 0
def evaluate(ids_require_verification: Sequence[int], model_generations: list[str]) -> str:
data_require_verification = eval_dataset.select(ids_require_verification)
model_labels = [None] * len(ids_require_verification)
max_workers = 16
with ThreadPoolExecutor(max_workers=max_workers) as executor: # Reduce the parallelization by 8 times
futures = {
executor.submit(
evaluate_text,
data_require_verification[idx],
generation
): idx
for idx, generation in enumerate(
[model_generations[x] for x in ids_require_verification]
)
}
for future in tqdm(as_completed(futures), total=len(ids_require_verification)):
idx = futures[future]
model_labels[idx] = future.result()
return np.array(model_labels)
ft_verif_labels = evaluate(ft_ids_require_verification, ft_model_generations)
ft_verif_labels[:5]
8%|▊ | 57/747 [00:38<06:08, 1.87it/s]
Cannot parse the answer <think> 1 The original sentence "And what are those risks?" is grammatically correct. All four experts agree that no changes are needed, and the proofreader's correction matches the original. Therefore, the proofreader's correction is valid.. Replacing with 0.
14%|█▍ | 107/747 [01:20<06:35, 1.62it/s]
Cannot parse the answer <think> 1. Replacing with 0.
20%|█▉ | 146/747 [01:48<07:02, 1.42it/s]
Cannot parse the answer <think> 1 The original sentence has several errors: "yonger" is misspelled as "younger," the verb "need" should be "needs" for third-person singular, and the phrase "need to more than" is grammatically incorrect. The proofreader's correction addresses all these issues by changing "yonger" to "younger," "need" to "needs," and removing the unnecessary "to." The corrected sentence matches the valid corrections provided by the experts (options 0, 2, 3), making the proofreader's correction valid.. Replacing with 0.
20%|██ | 150/747 [01:49<03:58, 2.51it/s]
Cannot parse the answer <think> 1. Replacing with 0.
31%|███ | 230/747 [02:41<05:40, 1.52it/s]
Cannot parse the answer <think> 1. Replacing with 0.
33%|███▎ | 244/747 [02:53<04:48, 1.74it/s]
Cannot parse the answer <think> 1 The original sentence "So what shall I do?" is grammatically correct. All four experts agreed that no correction was needed. The proofreader's correction matches the original, indicating they correctly identified no errors. Therefore, the proofreader's correction is valid.. Replacing with 0.
38%|███▊ | 281/747 [03:22<04:38, 1.67it/s]
Cannot parse the answer <think> 1 The original sentence has two errors: "strengthen" should be "strengthened" (passive voice with "will be"), and "how" should be "who" (referring to people/producers). The proofreader's correction addresses both errors. The experts' corrections also fix these issues, using either "who" or "that" (both acceptable here). The proofreader's version matches correction 0, which is valid.. Replacing with 0.
42%|████▏ | 313/747 [03:39<03:25, 2.11it/s]
Cannot parse the answer <think> 1 The original sentence is grammatically correct. All four experts agree that no changes are needed. The proofreader's correction matches the original, so it's valid.. Replacing with 0.
82%|████████▏ | 609/747 [06:08<01:35, 1.45it/s]
Cannot parse the answer <think> 1. The original sentence "Everybody knows each other." is grammatically correct. All four experts agree that no changes are needed. The proofreader's correction matches the original, so it's valid.. Replacing with 0.
84%|████████▍ | 626/747 [06:15<00:42, 2.84it/s]
Cannot parse the answer <think> 1. Replacing with 0.
86%|████████▌ | 641/747 [06:23<01:09, 1.53it/s]
Cannot parse the answer <think> 1 The original sentence has two main issues: the misspelling "pupluation" (corrected to "population") and the awkward/inaccurate use of "outburst" (corrected to "explosion"). The proofreader's correction fixes both errors. The experts' corrections also address these points (e.g., "population" and "explosion" in correction 3). The proofreader's version aligns with valid corrections, making it accurate.. Replacing with 0.
89%|████████▉ | 663/747 [06:32<00:24, 3.46it/s]
Cannot parse the answer The proofreader's correction fails to address several errors present in the original sentence and introduces new issues:
1. **Subject-verb agreement**: The original error "points is" (plural subject + singular verb) remains uncorrected as "points is" instead of "point is".
2. **Article omission**: "according the the passage" is corrected to "according to the passage", but the double "the" remains ("the the passage").
3. **Spelling error**: "know Old Kindgom King" remains uncorrected (should be "known Old Kingdom King").
4. **Verb tense inconsistency**: "carries and identifies" remains unchanged despite context requiring past tense.
5. **Capitalization error**: "khafre" at the end remains uncorrected to proper noun capitalization.
6. **New errors introduced**: The proofreader adds incorrect punctuation ("but the lecture says that, these evidence") and fails to fix the ungrammatical "these evidence" (should be "this evidence").
Since the proofreader's version retains multiple grammatical errors from the original and introduces new issues while failing to incorporate valid corrections from the expert suggestions, the correction is invalid.
Answer: 0. Replacing with 0.
100%|██████████| 747/747 [07:24<00:00, 1.68it/s]
CPU times: user 6.16 s, sys: 594 ms, total: 6.75 s Wall time: 7min 24s
array([1, 0, 1, 1, 1])
Let's do the same staff for the baseline model.
bs_verif_labels = evaluate(bs_ids_require_verification, bs_model_generations)
bs_verif_labels[:5]
19%|█▉ | 145/747 [01:21<04:47, 2.09it/s]
Cannot parse the answer <think> 1. Replacing with 0.
25%|██▍ | 184/747 [01:43<03:21, 2.79it/s]
Cannot parse the answer <think> 1 The original sentence "I think it will be lost." is grammatically correct. All four experts agree that no correction is needed. The proofreader's correction matches the original, confirming its validity. Since there are no errors to address, the proofreader's response is correct.. Replacing with 0.
31%|███▏ | 235/747 [02:08<02:53, 2.96it/s]
Cannot parse the answer <think> 1. Replacing with 0.
36%|███▌ | 270/747 [02:25<05:30, 1.44it/s]
Cannot parse the answer <think> 1 The original sentence "It can certainly be the case." is grammatically correct. All four experts agreed that no changes were needed. The proofreader's correction matches the original, indicating they found no errors. Since there are no mistakes to correct, the proofreader's response is valid. 1. Replacing with 0.
59%|█████▉ | 440/747 [03:56<02:02, 2.51it/s]
Cannot parse the answer <think>
1
The original sentence uses "creative" (adjective) and "innovation" (noun), which are mismatched. The correct versions should pair either both nouns ("creativity and innovation") or both adjectives ("creative and innovative"). The proofreader's correction changes "creative" to "creativity" (noun) to match "innovation" (noun), which aligns with the experts' corrections. Therefore, the proofreader's edit is valid.. Replacing with 0.
65%|██████▍ | 485/747 [04:20<02:24, 1.82it/s]
Cannot parse the answer <think> 1. Replacing with 0.
75%|███████▍ | 557/747 [04:52<01:14, 2.56it/s]
Cannot parse the answer <think> 0. Replacing with 0.
75%|███████▍ | 560/747 [04:54<01:36, 1.95it/s]
Cannot parse the answer <think>
1
The original sentence uses "think" instead of the past participle "thought" required by the present perfect tense ("Have you ever..."). All four experts agree on changing "think" to "thought," and the proofreader made this correction. The proofreader's edit addresses the only grammatical error, making the correction valid.. Replacing with 0.
82%|████████▏ | 610/747 [05:15<00:36, 3.76it/s]
Cannot parse the answer <think> 1. Replacing with 0.
95%|█████████▌| 712/747 [06:07<00:14, 2.49it/s]
Cannot parse the answer <think> 1. Replacing with 0.
100%|██████████| 747/747 [06:28<00:00, 1.92it/s]
array([1, 1, 1, 1, 1])
9- Measure accuracy of answers¶
Let's print the accuracy, build 95% confidence intervals, and see how our fine-tuned model performs against the baseline. Since the number of observations is quite large (747) and $p$ (observed accuracy) is not close to 0 or 1, we can use the normal approximation for a binomial proportion.
%%time
def print_confidence_interval(p, num_obs):
# Critical value for 95% CI
z = 1.96
deviation = z * ((p * (1 - p) / num_obs) ** .5)
left_boundary = p - deviation
right_boundary = p + deviation
print(f'95% confidence interval: {p:.3f} ± {deviation:.3f}\tLeft boundary: {left_boundary:.3f}\tRight boundary: {right_boundary:.3f}')
# Fine-tuned model
ft_verif_dict = {idx: label for idx, label in zip(ft_ids_require_verification, ft_verif_labels)}
ft_results.update(ft_verif_dict)
ft_accuracy = np.mean(list(ft_results.values()))
print('Fine-tuned model:')
print_confidence_interval(ft_accuracy, len(ft_results))
# Baseline model
bs_verif_dict = {idx: label for idx, label in zip(bs_ids_require_verification, bs_verif_labels)}
bs_results.update(bs_verif_dict)
# Coinciding ids
for idx in coinciding_ids:
bs_results[idx] = ft_verif_dict[idx]
bs_accuracy = np.mean(list(bs_results.values()))
print('Baseline model:')
print_confidence_interval(bs_accuracy, len(bs_results))
assert len(ft_results) == len(bs_results) == len(eval_dataset)
Fine-tuned model: 95% confidence interval: 0.838 ± 0.026 Left boundary: 0.812 Right boundary: 0.864 Baseline model: 95% confidence interval: 0.896 ± 0.022 Left boundary: 0.874 Right boundary: 0.918 CPU times: user 667 μs, sys: 0 ns, total: 667 μs Wall time: 637 μs
We can see the fine-tuned model slightly outperforms the baseline model on average with the scores staying within the confidence interval of each other. On the other side, the fine-tuned model works 2.5x times faster and reduces the token consumption due to the possibility of using shorter prompts, making the model both more efficient and effective.
Conclusion¶
In this tutorial, we've demonstrated the power of model distillation using Nebius Token Factory. By leveraging Qwen3-235B-A22B as our teacher model and fine-tuning Qwen3-4B as our student, we've created a grammar correction model that performs on par with a larger baseline Qwen3-14B model while being 3.5x times smaller and requiring less computational resources at inference time.
Nebius Token Factory streamlines the entire distillation workflow - from generating high-quality training data with powerful teacher models to fine-tuning efficient student models and seamlessly deploying them for inference. The end-to-end platform makes advanced AI techniques accessible even without extensive infrastructure or expertise. Start leveraging the power of model distillation for your own applications today and experience how Nebius Token Factory can help you achieve large capabilities in small packages.
References¶
- Stahlberg, F., & Kumar, S. (2021). Synthetic data generation for grammatical error correction with tagged corruption models. In Proceedings of the 16th Workshop on Innovative Use of NLP for Building Educational Applications (pp. 37–47). Association for Computational Linguistics. https://www.aclweb.org/anthology/2021.bea-1.4
- Qwen Team. (2025, April 29). Qwen3: Think deeper, act faster. Qwen Blog. https://qwenlm.github.io/blog/qwen3/
- Edward J. Hu, Yelong Shen, Phillip Wallis, Zeyuan Allen-Zhu, Yuanzhi Li, Shean Wang, Lu Wang and Weizhu Chen. LoRA: Low-Rank Adaptation of Large Language Models. The Tenth International Conference on Learning Representations, ICLR 2022, Virtual Event, April 25-29, 2022, 2022. https://openreview.net/forum?id=nZeVKeeFYf9
- Napoles, C., Sakaguchi, K., & Tetreault, J. (2017). JFLEG: A fluency corpus and benchmark for grammatical error correction. In Proceedings of the 15th Conference of the European Chapter of the Association for Computational Linguistics: Volume 2, Short Papers (pp. 229–234). Association for Computational Linguistics. http://www.aclweb.org/anthology/E17-2037
