本章涵盖:
- 将一个非常小的语言模型专门化,使其能够生成可运行的 Manim 代码
- Transformer 微调中的超参数调优过程
- 评估生成代码的质量
本章将通过一个端到端示例,演示如何针对特定任务微调一个小模型,也就是 GPT-2 small。这种方法同样适用于针对生成式任务微调任何类似 GPT 的模型。
3.1 数据准备
在本章中,我们将微调 GPT-2 small 模型,使其能够根据自然语言 prompt 生成可运行的 Manim 代码。Manim 是一个开源 Python 动画引擎,用于制作解释性数学视频------它可以通过程序化方式创建精确动画。图 3.1 展示了一个 Manim 代码示例及其渲染结果。

图 3.1 ------ Manim 代码示例,底部是代码,顶部是对应渲染结果
我选择 GPT-2 small 作为基线,不仅是因为它本身不能生成 Manim 代码,也是为了说明:你可以从一个小模型和一个高质量、领域专用的数据集开始,即便这个数据集相对较小,也仍然可以将模型专门化到某个任务,并获得良好性能。配套 Colab notebook 包含本章的完整源代码。需要硬件加速,也就是 GPU,免费层即可。
我们将使用 Hugging Face Hub 上的 manim_python 数据集。它有两个字段:instruction,也就是自然语言 prompt;以及 output,也就是对应的 Manim Python 代码。该数据集包含两个 split:train,有 599 个样本;test,有 51 个样本。
我们将使用第 2 章介绍过的 Hugging Face Datasets 库来处理数据集。首先需要下载它:
ini
from datasets import load_dataset
dataset = load_dataset("Edoh/manim_python")
下面的代码片段展示了训练 split 中的一个样本:
rust
{'instruction': "Create a new scene named 'MyScene'.", 'output': 'from
manim import * class MyScene(Scene): def construct(self): pass'}
接下来,我们加载模型 tokenizer:
ini
from transformers import GPT2Tokenizer
model_name = "openai-community/gpt2"
tokenizer = GPT2Tokenizer.from_pretrained(model_name)
tokenizer.pad_token = tokenizer.eos_token
该数据集还不能直接用于微调和测试模型,因此我们将实现一个自定义预处理函数:
ini
def preprocess_data(examples):
inputs = [
f"Instruction: {instr}\nOutput: {out}"
for instr, out in zip(examples["instruction"], examples["output"])
]
tokenized = tokenizer(inputs, truncation=True, max_length=512,
➥padding="max_length")
tokenized["labels"] = tokenized["input_ids"].copy()
return tokenized
preprocess_data 函数会把 instruction 和 output 两列拼接起来,然后对结果进行分词。它会被应用到数据集中的每个样本,包括 train 和 test 两个 split:
ini
tokenized_datasets = dataset.map(preprocess_data,
batched=True,
remove_columns=dataset["train"].column_names)
原始列会被删除,因为它们已经不再需要。
现在数据已经准备好了,我们可以开始准备模型训练。
3.2 微调
为了微调模型,我们将使用第 2 章介绍过的 Hugging Face Transformers 库,并结合 Optuna 进行超参数搜索。这个过程会系统性探索最合适的一组超参数,以提升模型性能。超参数不同于模型参数,它们并不会在训练阶段被模型自动学习。相反,它们是预先设定的。它们的精确配置会深刻影响模型性能,决定模型只是普通,还是表现出色。
Optuna 是一个与机器学习框架无关的开源 Python 库,用于自动超参数调优。它允许你使用标准 Python 条件语句、循环和语法来搜索最优超参数。由于 Optuna 得到了 Hugging Face 库的完整支持,我们将把它作为搜索后端使用,而不直接操作它的 API。
我们将定义一个自定义函数 model_init,用于超参数搜索,使每一次 trial 都从一个全新初始化的模型开始:
python
from transformers import GPT2LMHeadModel
def model_init():
return GPT2LMHeadModel.from_pretrained(model_name, device_map='auto')
接下来,在开始超参数搜索之前,我们配置训练参数:
ini
from transformers import TrainingArguments
training_args = TrainingArguments(
output_dir="./gpt2-manim-python-finetuned",
eval_strategy="epoch",
save_strategy="epoch",
logging_strategy="steps",
logging_steps=100,
save_total_limit=2,
load_best_model_at_end=True,
metric_for_best_model="eval_loss",
greater_is_better=False,
fp16=True,
report_to="none",
)
在前面的代码片段中,我们做了以下事情:
- 指定用于保存 checkpoint 候选项的根目录路径
- 选择
epoch作为评估和保存策略 - 选择
steps作为日志策略,也就是每 100 step 报告一次指标 - 设置单个 trial 中 checkpoint 保存数量的最大值
- 选择评估准确率作为选择最佳模型的关键指标
- 启用混合精度训练,也就是 FP16
因为我们设置了评估策略,所以会使用一部分训练数据进行评估。我们将拆分训练集,并保留 10% 的样本用于评估:
scss
train_val_split =
➥tokenized_datasets["train"].train_test_split(test_size=0.1)
tokenized_datasets["train"] = train_val_split["train"]
tokenized_datasets["validation"] = train_val_split["test"]
现在,我们可以使用训练参数以及已经分词的训练和评估数据初始化 Trainer:
ini
from transformers import (
Trainer,
DataCollatorForLanguageModeling,
EarlyStoppingCallback
)
data_collator = DataCollatorForLanguageModeling(tokenizer=tokenizer,
mlm=False)
trainer = Trainer(
model_init=model_init,
args=training_args,
train_dataset=tokenized_datasets["train"],
eval_dataset=tokenized_datasets["validation"],
tokenizer=tokenizer,
data_collator=data_collator,
callbacks=[EarlyStoppingCallback(early_stopping_patience=2)],
)
我们还添加了一个 callback:如果某个训练 trial 连续两步没有改善,就停止该 trial。
我们将按如下方式定义超参数调优的搜索空间:
css
def hp_space(trial):
return {
"learning_rate": trial.suggest_float("learning_rate",
➥1e-5, 5e-4, log=True),
"per_device_train_batch_size":
➥ trial.suggest_categorical("per_device_train_batch_size",
➥ [2, 4, 8]),
"weight_decay": trial.suggest_float("weight_decay", 0.0, 0.3),
"num_train_epochs": trial.suggest_int("num_train_epochs", 3, 6),
"warmup_steps": trial.suggest_int("warmup_steps", 0, 500),
"gradient_accumulation_steps":
➥ trial.suggest_categorical("gradient_accumulation_steps",
➥ [1, 2, 4]),
}
我们定义了一个函数,它接收一个 trial 对象作为输入,Optuna 将使用它来采样超参数值。在 hp_space 函数体中,我们指定取值范围来定义搜索空间。这里的超参数包括学习率、每个设备上的 batch size、权重衰减、训练 epoch 数、warmup steps,以及梯度累积步数。其中一些范围是为了适配 Colab 免费层虚拟机中可用的计算能力,也就是单张 NVIDIA Tesla T4。在其他硬件环境下,一些超参数,例如 batch size 或梯度累积步数,可能需要不同的值或范围。
注意
梯度累积步数是指,在模型权重更新之前,梯度会跨多少个 mini-batch 进行累积。这可以在不超过 GPU 内存限制的情况下实现更大的有效 batch size,尤其适合 GPU 内存紧张的情况。
现在可以运行超参数搜索了。这里我们将指定后端引擎,本例中为 Optuna;trial 数量;以及用于比较 trial 的指标,本例中是 evaluation loss:
ini
best_run = trainer.hyperparameter_search(
direction="minimize",
backend="optuna",
n_trials=10,
hp_space=hp_space,
compute_objective=lambda metrics: metrics["eval_loss"],
)
搜索完成后,我们可以使用存储在 best_run 变量中的最佳超参数配置 trainer:
ini
for key, value in best_run.hyperparameters.items():
setattr(training_args, key, value)
trainer = Trainer(
model_init=model_init,
args=training_args,
train_dataset=tokenized_datasets["train"],
eval_dataset=tokenized_datasets.get("validation"),
tokenizer=tokenizer,
data_collator=data_collator,
callbacks=[EarlyStoppingCallback(early_stopping_patience=2)],
)
最后,我们可以开始调优模型:
scss
trainer.train()
训练完成后,我们会把微调后的模型和 tokenizer 保存到磁盘,这样就可以重新加载它们,用于测试或生成 Manim 代码:
arduino
trainer.save_model("./gpt2-manim-python-finetuned")
tokenizer.save_pretrained("./gpt2-manim-python-finetuned")
接下来,我们将看看如何测试模型。
3.3 测试微调后的模型
为了在测试集上测试微调后的模型,我们首先从磁盘加载它,并放入 GPU 内存:
ini
import torch
model_dir = "./gpt2-manim-python-finetuned"
tokenizer = GPT2Tokenizer.from_pretrained(model_dir)
model = GPT2LMHeadModel.from_pretrained(model_dir)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model.to(device)
model.eval()
接下来,我们定义一个自定义函数 generate_output。我们会在测试数据集中每个 prompt,也就是 instruction 列上运行它,以生成期望的 Manim 代码,而 output 列就是我们的 ground truth。该函数将 prompt 作为第一个参数,同时接受最大生成长度,以及 temperature、解码策略等可选参数,本例中包括 beam search 和 nucleus sampling:
ini
def generate_output(instruction, max_length=150, num_beams=5,
➥ temperature=0.7, top_p=0.9, repetition_penalty=1.2):
prompt 被分词之后:
ini
prompt = f"Instruction: {instruction}\nOutput:"
input_ids = tokenizer.encode(prompt, return_tensors="pt").to(device)
从输入文本生成 Manim 代码的过程如下:
ini
generated_ids = model.generate(
input_ids,
max_length=max_length,
num_beams=num_beams,
temperature=temperature,
top_p=top_p,
repetition_penalty=repetition_penalty,
do_sample=True,
pad_token_id=tokenizer.eos_token_id,
eos_token_id=tokenizer.eos_token_id,
early_stopping=True,
no_repeat_ngram_size=2,
)
生成的 token 会被解码,并跳过特殊 token:
ini
generated_text = tokenizer.decode(generated_ids[0],
skip_special_tokens=True)
最后,生成文本会返回给调用者:
ini
output_start = generated_text.find("Output:")
if output_start != -1:
output_text = generated_text[output_start + len("Output:"):].strip()
else:
output_text = generated_text.strip()
return output_text
我们会把测试结果持久化到 CSV 文件中,以便后续更容易分析:
ini
import csv
output_csv = "gpt2_manim_python_test_outputs.csv"
with open(output_csv, mode="w", newline="", encoding="utf-8") as csvfile:
writer = csv.DictWriter(csvfile,
fieldnames=["instruction", "reference_output", "generated_output"])
writer.writeheader()
我们会遍历测试样本,调用 generate_output 函数为每条 instruction 生成 Manim 代码,并把结果连同 prompt 和 ground truth 一起追加到 CSV 文件中:
css
for example in dataset['test']:
instruction = example["instruction"]
reference_output = example["output"]
generated_output = generate_output(instruction)
writer.writerow({
"instruction": instruction,
"reference_output": reference_output,
"generated_output": generated_output,
})
下面分析一些有意义的例子。这些例子是在按前述方式完成两轮超参数搜索和微调后,由微调模型在测试时生成的。
给定如下 prompt:
bash
Scale the triangle by a factor of 2 in 2 seconds.
模型生成了如下 Manim 代码:
scss
from manim import *
class MyScene(Scene):
def construct(self):
triangle = RegularPolygon(n=3, radius=2, color=YELLOW)
self.add(triangle)
self.play(triangle.animate.scale(2), run_time=2)
rectangle.move_to((UP * 1. 5))
前面的代码除了最后一行之外都是正确的。删除最后一行后,代码就会按 prompt 指定的方式运行:它创建一个 Manim scene,添加一个三角形,并在 2 秒内把三角形尺寸放大一倍。
再看另一个例子。考虑如下 prompt:
css
Create a point at coordinates (0, 0) and color it pink.
模型生成了如下 Manim 代码:
arduino
from manim import *
class MyScene(Scene):
def construct(self):
point = Point(width=4, height=2) color=PINK)
self.add(point)
前面的代码可以运行,并且满足 prompt 的要求,除了该点被放在了 (4, 2),而不是要求的 (0, 0)。在这个实验的多次测试中,微调模型经常难以为 Manim 对象设置数值参数。
在这个实验中,整体幻觉率较低,并且主要限于刚才描述的两种情况。我们可以通过运行更多超参数搜索 trial 来降低,甚至消除这些问题,至少 3 次;对 manim_python 数据集来说,5 次可能最好。也可以尝试不同采样策略,降低最大生成 token 数,或者两者一起使用。即便如此,这个例子仍然说明,一个在经过整理的领域专用数据集上训练的小模型,可以很快获得某个领域或任务中的专业能力。
3.4 领域专用评估
许多统计指标都可以在测试阶段评估语言模型的性能。这些指标能为通用领域任务提供有用洞察,尤其是那些只基于非结构化自然语言的任务。但当你在特定领域进行 benchmark 时,无论模型是大是小,是否经过微调,只要输出偏离人类语言,你仍然需要领域专家,也就是 SME,进行定性审查,有时还需要外部策略和工具。基于本章讨论过的 Manim 数据集微调模型,我们将看看一些下游策略,用于自动化评估生成代码,并减轻 SME 的负担。
在这个案例中,一个完整评估流水线可以包括以下自动化步骤:
- 语法检查------生成内容是 Python 代码,因此必须确保它在语法上有效。
- Manim API 使用的静态分析------解析生成代码,检查预期的 Manim class、method 或 function call。
- 在确认生成的 Manim 代码语法有效之后,需要验证它是否能够按预期完成执行。
- 可选项------验证渲染结果是否正确,因为 Manim 输出始终是图像或动画。
你可以使用 Python 内置的 ast 包,通过自定义函数实现语法检查:
python
import ast
def is_syntax_valid(code_str):
try:
ast.parse(code_str)
return True, ""
except SyntaxError as e:
return False, str(e)
注意
Python 的 ast 包,也就是 Abstract Syntax Trees,可以让你处理 Python 的抽象语法树。我们将在第 7 章中,在使用 SLM 生成 Python 代码的语境下介绍它。
is_syntax_valid 函数会调用 ast.parse。如果生成代码是有效 Python 语法,就返回 True;否则返回 False。它只检查语法,不检查代码是否使用了某个特定 API,但这是一个有用的第一步,可以在运行更复杂检查之前捕获无效模型响应。
下一步是针对 Manim API 的静态分析。它仍然会使用 ast,但会针对被测试的包进行定制。为此,我们可以创建一个专用类,继承 ast 模块中的 NodeVisitor:
ini
from ast import NodeVisitor
class ManimCodeAnalyzer(ast.NodeVisitor):
def __init__(self):
self.imports_manim = False
self.scene_subclass_names = []
self.play_calls = 0
self.create_calls = 0
self.errors = []
这个类包含几个 utility function,它们会覆盖父类的 visit 方法,并且必须以 visit_ 前缀命名。第一个函数会验证生成代码中是否直接导入了 Manim 包:
ruby
def visit_Import(self, node):
for alias in node.names:
if alias.name == "manim":
self.imports_manim = True
self.generic_visit(node)
另一个函数会检查是否通过 from ... import 的方式导入了 Manim 元素:
ruby
def visit_ImportFrom(self, node):
if node.module and node.module.startswith("manim"):
self.imports_manim = True
self.generic_visit(node)
Manim 程序通常被定义为一个 Scene 类,或者其子类。因此添加一个函数来检查生成的类是否是 Scene 或 Scene 的子类,会很有用:
python
def visit_ClassDef(self, node):
for base in node.bases:
if isinstance(base, ast.Name) and base.id == "Scene":
self.scene_subclass_names.append(node.name)
elif isinstance(base, ast.Attribute):
if base.attr == "Scene":
self.scene_subclass_names.append(node.name)
self.generic_visit(node)
最后,我们需要一个函数来统计 Scene 子类中对 play 方法的调用,以及 Manim Create 初始化的调用次数:
python
def visit_Call(self, node):
if isinstance(node.func, ast.Attribute):
if (isinstance(node.func.value, ast.Name) and
➥node.func.value.id == "self"
and node.func.attr == "play"):
self.play_calls += 1
if isinstance(node.func, ast.Name) and node.func.id == "Create":
self.create_calls += 1
self.generic_visit(node)
现在可以定义一个使用 ManimCodeAnalyzer 类的函数:
scss
def analyze_manim_code(code_str):
analyzer = ManimCodeAnalyzer()
analyze_manim_code 首先检查输入代码的 Python 语法:
python
try:
tree = ast.parse(code_str)
except SyntaxError as e:
return {
"syntax_valid": False,
"syntax_error": str(e),
"imports_manim": False,
"scene_subclass_names": [],
"play_calls": 0,
"create_calls": 0,
}
如果 Python 代码无效,执行会停止,并且函数会返回关于所发现问题的洞察。如果代码有效,函数会使用自定义 analyzer 对它进行 Manim API 解析:
arduino
analyzer.visit(tree)
最后,它返回所执行静态分析的摘要:
python
return {
"syntax_valid": True,
"syntax_error": None,
"imports_manim": analyzer.imports_manim,
"scene_subclass_names": analyzer.scene_subclass_names,
"play_calls": analyzer.play_calls,
"create_calls": analyzer.create_calls,
}
举例来说,给定如下 Manim 代码片段:
python
from manim import *
class MyScene(Scene):
def construct(self):
circle = Circle()
self.play(Create(circle))
analyze_manim_code 函数会返回如下结果:
vbnet
syntax_valid: True
syntax_error: None
imports_manim: True
scene_subclass_names: ['MyScene']
play_calls: 1
create_calls: 1
执行生成代码可以遵循如下工作流:
- 将生成代码保存到
.py文件中。 - 运行 Manim CLI 来渲染 scene。
- 检查渲染是否成功。
你可以通过定义一个自定义函数来覆盖这三个步骤。该函数会把生成的 Manim 代码片段和 Scene 类名称作为输入:
ini
def evaluate_manim_code(code_str, scene_class_name="CustomScene"):
首先,输入的 Manim 代码,也就是 code_str,被保存到文件中:
python
import os
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
code_path = os.path.join(tmpdir, "generated_scene.py")
with open(code_path, "w") as f:
f.write(code_str)
接下来,设置运行 Manim CLI 的命令:
ini
cmd = [
"manim",
"-ql",
code_path,
scene_class_name,
]
它会执行该 Python 文件以渲染 scene:
ini
import subprocess
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
success = result.returncode == 0
output = result.stdout + "\n" + result.stderr
except subprocess.TimeoutExpired:
success = False
output = "Timeout expired during rendering."
在 CLI 配置中,我们使用了 -ql,也就是 low quality 选项,以降低计算负担并加快测试速度。
接下来,使用语法检查步骤中相同的 Manim 片段,验证生成代码是否可以正确运行:
ini
generated_code = """
from manim import *
class MyScene(Scene):
def construct(self):
circle = Circle()
self.play(Create(circle))
"""
我们使用 evaluate_manim_code 验证生成的 Manim 片段是否可以正确运行。以下代码:
lua
success, output = evaluate_manim_code(generated_code,
scene_class_name="MyScene")
print("Render success:", success)
print("Output:", output)
会产生类似如下输出:
vbnet
Render success: True
Output: Manim Community v0.19.0
[08/04/25 14:03:42] INFO Animation 0 : Using cached
cairo_renderer.py:89
data (hash :
1185818338_3789429803_22313245
7)
INFO Combining to Movie file.
scene_file_writer.py:739
INFO
scene_file_writer.py:886
File ready at
'/content/media/videos/gen
erated_scene/480p15/MyScen
e.mp4'
INFO Rendered MyScene
scene.py:255
Played 1 animations
运行 evaluate_manim_code 也会把生成的 scene 或动画保存为本地 MP4 文件。如果你的测试数据集包含参考视频或图片,你可以提取帧或缩略图,计算生成图像与参考图像之间的相似度指标,例如 SSIM 或 LPIPS,并将它们与质量阈值进行比较。这类验证超出了本章范围,但你可以通过把这些指标与自己选定的阈值进行检查来实现它。
注意
结构相似性指标,也就是 SSIM,用于预测数字电视、电影图像以及其他数字图像和视频的感知质量。它也可以衡量两张图像之间的相似性。Python 图像处理包 scikit-image 提供了可直接使用的实现。Learned Perceptual Image Patch Similarity,也就是 LPIPS,是一种感知相似度指标,使用深度网络激活来衡量图像 patch 之间的距离。它也可以作为图像优化中的感知损失。
以下是针对这类任务验证执行和渲染正确性的一些最终建议:
- 沙箱化------运行任意生成代码是有风险的。使用沙箱工具,例如 Docker 容器或受限环境,来隔离执行。
- 超时------设置超时,以防止挂起或无限循环。
- 资源限制------在渲染过程中限制 CPU、GPU 和内存使用,以节省时间和金钱。
到这里,我们完成了关于如何针对特定领域和任务调优 SLM 的介绍。下一章中,我们将把重点转向本书的核心主题:在硬件受限环境中进行 SLM 优化、压缩和部署。
总结
- GPT-2 small 模型可以用几百个样本的数据集进行领域专用任务微调。
- Manim 是一个 Python 动画引擎,用于通过代码创建数学可视化。
- 在为语言模型训练进行分词之前,要在预处理阶段拼接 instruction 和 output 字段。
- Optuna 可以自动化超参数搜索,并与 Hugging Face Transformers 库集成。
- 在搜索期间,模型初始化函数会为每个超参数 trial 创建一个全新的模型实例。
- 训练参数会设置评估策略、日志间隔、checkpoint 保存和混合精度训练。
- 当验证损失停止改善时,early stopping callback 会停止训练,以防止过拟合。
- 超参数搜索空间定义学习率、batch size、权重衰减、epoch 数以及其他参数的范围。
- 你可以使用 Python 的
ast模块验证生成代码的语法。 - 静态分析可以验证生成代码中特定领域 API 的使用模式。
- 自定义
NodeVisitor类可以扩展ast功能,用于分析框架专用代码结构。 - 执行测试会通过命令行工具运行生成代码,以验证功能正确性。
- 渲染验证会通过相似度指标,将生成输出与参考图像进行比较。
- 在自动运行生成代码时,沙箱化和超时可以提高安全性。
- 小型语言模型在经过整理的领域专用数据集上,能够很好地完成专门化任务。
- 自动化评估流水线可以减少领域专家评估代码质量的负担。