从 Qwen/Qwen3-1.7B-Base 出发,在单张 RTX 4090 上学习和复现 Python 函数代码生成的后训练:数据清洗 → Full SFT / LoRA SFT → DPO / 奖励模型 + PPO / GRPO → 同一题集评测。这里的重点是读懂每一步的数据流、训练目标和评测约束;它是学习与实验项目,不是生产级训练框架,也不宣称达到最优代码模型效果。
English: A hands-on, reproducible learning project for Qwen3-1.7B code post-training, covering SFT, LoRA, DPO, PPO, GRPO and EvalPlus evaluation.
建议从已验证结果了解当前进度,再按代码架构和阅读路线进入实现。每次训练和评测写入独立目录,便于之后横向比较。
- 已实现模型版本锁定、SFT/RL 数据准备、Full SFT、LoRA SFT、DPO、奖励模型、PPO、GRPO、统一生成、Docker 隔离判分和最终对比脚本。核心训练和数据处理函数附有中文注释,适合对照代码学习。
- Base、Full SFT、LoRA SFT 已完成:两次 SFT 均在单张 RTX 4090 上训练,三个模型均在本机对全部 542 题生成代码并用 Docker 判分。逐题生成、判分和独立摘要分别保存在
results/base/20260922T010155Z/、results/full_sft/full-v1/、results/lora_sft/lora-v1/。训练权重放在 Hugging Face,GitHub 只保存代码、数据处理记录与评测证据。 - 当前结果见下图和训练与评测记录。DPO、奖励模型、PPO、GRPO 尚未完成正式训练与评测。
- 公开评测固定为 HumanEval+ v0.1.10(164 题)和 MBPP+ v0.2.0(378 题)。
eval.jsonl与官方测试快照的哈希见eval.lock.json。独立自建复核题尚未加入,先不要把现有结果称为最终实验结论。 - 标准答案直接判分的自检为 HumanEval+ 163/164、MBPP+ 377/378;HumanEval/32 和 Mbpp/255 连题库自带的答案也未通过当前测试。所有模型仍按完整 164/378 题报告,逐题记录保留。
| 模型 | HumanEval+ | MBPP+ |
|---|---|---|
| Base | 31/164(18.9%) | 214/378(56.6%) |
| Full SFT | 67/164(40.9%) | 229/378(60.6%) |
| LoRA SFT | 82/164(50.0%) | 237/378(62.7%) |
表中使用相同的冻结题集、原始 prompt、贪心解码和严格 EvalPlus+ 测试;每题只生成一次。LoRA SFT 在这两套测试中最高,但这只说明当前训练与评测设置下的表现,不代表其它代码任务也有相同排序。详见训练配置、逐题结果与局限。
| 模型 | 下载与模型卡 | 使用时需要什么 |
|---|---|---|
Full SFT full-v1 |
Qwen3-1.7B-Python-Code-Full-SFT | 完整模型,直接用 Transformers from_pretrained 加载。 |
LoRA SFT lora-v1 |
Qwen3-1.7B-Python-Code-LoRA-SFT | 仅包含适配器;先加载 models.lock.json 锁定的 Base,再用 PEFT 叠加。 |
复现实验时请固定 Hugging Face 提交号:Full SFT 为 aed510bf517353217c77e001b8f2940aa140da2d,LoRA SFT 为 2f8982c3bebe1479f4834e246c98aa2dfecbc69e。
模型卡包含训练设置、评测口径、限制和最小推理示例。二者都是原始代码续写模型,输入与评测一致的 Python 函数题干;不要把这里的通过率理解为聊天助手或完整软件工程任务的能力。
锁定 Base 权重 ──┬── Base 评测
├── Full SFT ────────────────────────── 评测
├── LoRA SFT ─┬─────────────────────── 评测
│ ├── DPO ──────────────── 评测
│ ├── PPO ──────────────── 评测
│ └── GRPO ─────────────── 评测
└── 偏好数据训练奖励模型 ── 给 PPO 打分
DPO、PPO、GRPO 的策略模型均从同一个 LoRA SFT 适配器出发;PPO 使用的奖励模型则单独从 Base 和偏好数据训练。DPO 与奖励模型共用 data/preference/;PPO 和 GRPO 共用 data/rl/ 的题干。GRPO 奖励来自训练数据自带的测试,绝不读取 EvalPlus 的隐藏测试。
| 代码入口 | 输入 → 输出 | 为什么这样设计 |
|---|---|---|
download_models.py、models.lock.json |
官方模型 → 固定提交号的本地 Base / 参考模型 | 避免上游模型更新后,同一实验名对应不同权重。 |
freeze_eval.py、eval.lock.json |
EvalPlus 官方题库 → eval.jsonl 与测试快照 |
训练前固定题目、版本和 SHA-256;训练处理只读取题干,不读取隐藏测试。 |
prepare_sft.py |
OpenCodeInstruct → data/sft/{train,valid}.jsonl |
只保留测试全过、可解析且含函数的 Python 代码;按题干去重,排除与评测题干明显重合的样本。输出 prompt / completion 两列。 |
prepare_rl.py |
SFT 来源记录 → data/rl/{train,valid}.jsonl |
保留训练题自带的 tests,供 GRPO 算执行奖励;不使用公开评测的隐藏测试。 |
export_themis.py → prepare_preference.py |
Themis Python 功能正确性偏好 → prompt / chosen / rejected |
DPO 与奖励模型共用同一批偏好对;过滤语法错误、重复和评测重合。此数据阶段尚未正式完成。 |
所有处理后的数据都有对应 *.lock.json,记录源版本、输出哈希与评测版本。脚本发现已存在的冻结输出时会停止,防止重跑悄悄覆盖实验输入。
| 阶段与入口 | 读入什么 | 训练目标与产物 | 当前状态 |
|---|---|---|---|
train_sft.py --mode full/lora |
同一份 SFT prompt / completion、锁定 Base |
只对代码答案计算交叉熵:题干 token 的标签为 -100。Full 更新全部参数;LoRA 只更新低秩矩阵。各自保存 final_model/ 和 training_meta.json。 |
已在 4090 训练并完整评测 |
train_dpo.py |
LoRA SFT、chosen / rejected |
比较同一题两份答案在可训练策略和冻结参考策略下的对数概率,直接优化偏好差;手写 PyTorch 损失。 | 代码已实现,尚未正式训练 |
train_reward.py → train_ppo.py |
偏好对训练的奖励模型、LoRA SFT、RL 题干 | 奖励模型先学习给答案打分;PPO 再采样回答,用奖励、KL、GAE、裁剪损失更新 LoRA 与价值头。PPO 循环自行实现。 | 代码已实现,尚未正式训练 |
train_grpo.py + code_reward.py |
LoRA SFT、RL 题干及训练测试 | 每题采样 4 份代码,在隔离容器里执行训练测试,以组内相对得分更新策略;手写 PyTorch 损失。 | 代码已实现,尚未正式训练 |
train_common.py 负责后续阶段共用的运行目录、锁文件校验与适配器路径检查。DPO、PPO、GRPO 都从同一份 LoRA SFT 分叉,便于比较三种偏好优化路线;这不意味着它们已取得正式效果。
eval.jsonl + 模型
→ generate_eval.py 只生成代码,保存原始 completion
→ score_eval.py 在禁网、限资源 Docker 内运行 EvalPlus 测试
→ summarize_eval.py 校验题目数和判分哈希,计算各阶段 pass@1
→ compare_eval.py 六阶段完成后再生成最终横向对比
generate_eval.py 对完整模型直接加载权重,对 LoRA 自动加载锁定 Base 再叠加适配器。它不执行生成代码;score_eval.py 才把代码交给 Docker。两套公开题始终使用同一原始 prompt、贪心解码、每题一次生成和最多 512 个新 token。按阶段保存原始输出、逐题判分、配置与摘要,避免只留下一个无法追溯的总分。
- 看
eval.jsonl、freeze_eval.py:先理解“同一套题”怎样固定下来。 - 看
prepare_sft.py和data/examples/:跟踪一条原始记录如何变为prompt/completion。 - 看
train_sft.py:重点找标签掩码、Full 与 LoRA 的参数更新范围、最佳 checkpoint 的选择。 - 看
generate_eval.py→score_eval.py→summarize_eval.py:用一条题目追踪从生成代码到 pass@1 的全过程。 - 再看
prepare_preference.py→train_dpo.py→train_reward.py/train_ppo.py→train_grpo.py:比较各方法的训练信号从哪里来、哪些模型被冻结。
服务器建议 Python 3.11,先按 CUDA 驱动安装匹配的 PyTorch,再安装:
公开仓库随附已处理的 data/sft/、data/rl/、对应锁文件及 Base 判分结果。
服务器克隆后无需重新运行 prepare_sft.py 或 prepare_rl.py;这两个脚本用于从原始数据重新构建,遇到已有数据会停止,以免覆盖冻结版本。
Base 和官方参考模型的权重没有随 GitHub 仓库上传;服务器运行 download_models.py,按 models.lock.json 的提交号下载。已训练的 Full SFT / LoRA SFT 权重从上面的 Hugging Face 模型仓库获取。
python -m pip install -r requirements.txt
docker build -f Dockerfile.eval -t qwen-code-eval:0.3.1 .
python scripts/download_models.pySFT 数据源是固定版本的 NVIDIA OpenCodeInstruct 的第一份 100,000 行分片。脚本只保留原数据记录的测试全过、单个 Python 代码块、可解析且含顶层函数的样本,并按题干去重、与公开评测题干做连续词重合检查。当前得到训练 26,805 条、验证 1,386 条;data/sft.lock.json、data/rl.lock.json 保存源文件与输出哈希。连续词检查不能保证发现所有语义近似题,正式报告应如实注明。
随仓库发布的 SFT 和 RL 数据是从 NVIDIA OpenCodeInstruct(CC BY 4.0)筛选并转换得到的版本;原始仓库、固定提交号、原始分片哈希及转换后的哈希见 data/sft.lock.json 和 data/rl.lock.json。仓库未包含原始 Parquet 分片。
偏好数据尚未下载。scripts/export_themis.py 可从固定提交号的 Themis-CodePreference 导出 Python 功能正确性子集,并把原始类别整数转成可读名称。然后运行:
python scripts/export_themis.py
python scripts/prepare_preference.py --source data/raw/themis-python-fc.jsonl --source-name project-themis/Themis-CodePreference@7c366b23590cc9ff8d372bb47280fcd474536344第二步会统一成代码题干与代码答案,检查长度、语法、去重和评测重合,再生成 DPO/奖励模型共同使用的训练/验证集与锁文件。导出整套原始数据较大,因此本机尚未运行这两步。
SFT 已在服务器完成;以下命令用于复现训练,<编号> 是脚本输出目录中的运行编号:
python scripts/train_sft.py --mode full
python scripts/train_sft.py --mode lora有 data/preference/ 后可以跑 DPO 与奖励模型,再跑 PPO;GRPO 只需要前面的 data/rl/:
python scripts/train_dpo.py --sft-run runs/lora_sft/<编号>
python scripts/train_reward.py
python scripts/train_ppo.py --sft-run runs/lora_sft/<编号> --reward-run runs/reward/<编号>
python scripts/train_grpo.py --sft-run runs/lora_sft/<编号>DPO、PPO、GRPO 的训练循环和损失都由本项目的 PyTorch 代码实现,不调用 TRL 训练器:DPO 对同一题的 chosen/rejected 计算 policy 与冻结 reference 的答案概率差;PPO 用奖励模型分数、KL、GAE 和裁剪损失更新 LoRA 与价值头;GRPO 每题采样 4 份代码,用训练题自带测试的通过比例计算组内优势,再做两遍裁剪更新。GRPO 的 beta=0 不额外驻留 reference;相同得分的组不更新。SFT 与奖励模型仍使用 TRL。
每次训练独立保存 final_model/、training_meta.json 和日志;DPO、GRPO 保存中间适配器检查点,PPO 另存价值头。SFT、DPO、奖励模型用各自的验证 loss 选择模型,评测集不参与选择。GRPO 的 --max-steps 表示最多采样多少组题目;PPO 的 --max-steps 表示最多执行多少条 rollout。训练脚本要求恰好一张 CUDA 卡,数据和显卡检查在创建运行目录前完成。DPO、奖励模型、PPO、GRPO 的正式单卡训练仍需服务器验证。
python scripts/generate_eval.py --stage base --model models/base
python scripts/score_eval.py results/base/<编号>
python scripts/summarize_eval.py results/base/<编号>Full SFT 用 --stage full_sft --model runs/full_sft/<编号>/final_model;LoRA SFT、DPO、PPO、GRPO 分别指向自己的 final_model/。LoRA 评测会自动加载本项目锁定的 Base 底座再叠加适配器。参考模型可用 --stage reference --model models/reference 单独测,不进入必需的六阶段集合。
固定生成口径:EvalPlus 原始 prompt;贪心解码;每题只生成一次;最多 512 个新 token;直接判模型原始代码,不做会误删辅助函数的自动清洗。只允许完整 164+378 题进入正式汇总。模型生成代码只在禁网、只读根文件系统和资源受限的 Docker 内执行。
最后显式传入六个阶段的结果目录,避免脚本误选“最新一次”:
python scripts/compare_eval.py \
results/base/<编号> results/full_sft/<编号> results/lora_sft/<编号> \
results/dpo/<编号> results/ppo/<编号> results/grpo/<编号>它在 results/comparisons/<编号>/ 保存表格和完整元数据。每个阶段自己的逐题输出、逐题判分、配置、pass@1、生成耗时与训练日志仍在原目录。DPO、GRPO 默认各有 4 小时墙钟上限;奖励模型和 PPO 各有 2 小时上限。墙钟不是精确 GPU 使用时长,正式比较还要结合显卡监控和训练日志记录实际计算量。
python -m unittest discover -s tests -v
python -m compileall -q scripts tests这些检查覆盖训练题干去重、PPO token 对齐和优势计算;小模型初始化另验证了 SFT 标签掩码、DPO/奖励模型/GRPO 的训练器输入。它们不能替代尚未完成的 DPO、奖励模型、PPO、GRPO 正式训练。