23. Lab: Full MoE Pipeline (SFT → DPO → RM → PPO)
Difficulty: Highest | Estimated Time: 24-48 hours on a single RTX 5090 | Path: SFT -> DPO -> RM -> PPO (full)
This lab is a full pipeline exercise that imbues a single model with the core capabilities of state-of-the-art 2025-2026 LLMs. It implements the model characteristics commonly pursued by DeepSeek-V3/R1's MoE + reasoning, GPT o-series' extended thinking, and Claude's structured tool use through 4-stage training.
1. Lab Objective
Final Model Profile: Korean/English bilingual + MoE expert specialization + structured reasoning + code generation
| Capability | Target Level | Related SOTA Models |
|---|---|---|
| Structured reasoning (thinking) | Output thought process via <think> tags |
GPT o1/o3, DeepSeek-R1 |
| Code generation | Function implementation + explanation | Claude, GPT-4o |
| Math problem solving | Step-by-step formula derivation | DeepSeek-V3, Qwen-2.5-Math |
| Korean conversation | Natural Korean responses | Existing KoAlpaca data |
| MoE specialization | Per-expert domain specialization | DeepSeek-V3, Mixtral |
| Preference alignment | Prefer accurate and safe responses | Full RLHF path (DPO -> RM -> PPO) |
2. Full Pipeline
Phase 1: Data Collection + Conversion (1-2 hours)
│
Phase 2: SFT — Multi-Domain Foundation (6-8 hours)
│ ├── Korean conversation (KoAlpaca)
│ ├── Math (GSM8K + MATH)
│ ├── Coding (CodeAlpaca + CodeFeedback)
│ └── Thinking patterns (rule conversion + LLM generation)
│
Phase 3: DPO — Multi-Domain Preference Alignment (4-6 hours)
│ ├── Math correct/incorrect preference
│ ├── Thinking/Non-thinking preference
│ └── Korean response quality preference
│
Phase 4: RM — Reward Model Training (3-4 hours)
│
Phase 5: PPO — RLHF Policy Optimization (4-6 hours)
│
Phase 6: Bench — Original vs Final Model Comparison
3. Data Collection Guide
Common Data Collection Guide: Refer to 19_data_collection.md for collection scripts and size adjustment methods for all datasets (SFT/DPO, Korean/English, math/coding). Below is a mixing guide specific to this lab.
3.1 SFT Data -- 4-Domain Mix
| Domain | Dataset | Scale | Source |
|---|---|---|---|
| Korean conversation | KoAlpaca | 10K | data/sft_10k_raw.jsonl (already available) |
| Math | GSM8K | 8.5K | HuggingFace gsm8k |
| Math (advanced) | MATH | 7.5K | HuggingFace hendrycks/competition_math |
| Coding | CodeAlpaca | 10K | HuggingFace sahil2801/CodeAlpaca-20k |
| Thinking patterns | Converted from above data | 5-10K | Rule/LLM generation |
Total SFT data: ~40K rows (shuffled after mixing)
# -- 3.1.1 Korean: already available
cp data/sft_10k_raw.jsonl data/pipeline/ko_sft.jsonl
# -- 3.1.2 Math (GSM8K)
python -c "
from datasets import load_dataset
import json
ds = load_dataset('gsm8k', 'main', split='train')
with open('data/pipeline/math_gsm8k_sft.jsonl', 'w') as f:
for row in ds:
prompt = f'Solve the following math problem step by step.\n\nProblem: {row[\"question\"]}'
response = row['answer']
f.write(json.dumps({'prompt': prompt, 'response': response}, ensure_ascii=False) + '\n')
print(f'GSM8K: {len(ds)} rows')
"
# -- 3.1.3 Math advanced (MATH)
python -c "
from datasets import load_dataset
import json
ds = load_dataset('hendrycks/competition_math', split='train')
with open('data/pipeline/math_competition_sft.jsonl', 'w') as f:
count = 0
for row in ds:
prompt = f'Solve this math problem. Show your work.\n\n{row[\"problem\"]}'
response = row['solution']
if prompt and response:
f.write(json.dumps({'prompt': prompt, 'response': response}, ensure_ascii=False) + '\n')
count += 1
print(f'MATH: {count} rows')
"
# -- 3.1.4 Coding
python -c "
from datasets import load_dataset
import json
ds = load_dataset('sahil2801/CodeAlpaca-20k', split='train')
with open('data/pipeline/code_sft.jsonl', 'w') as f:
count = 0
for row in ds:
prompt = row.get('instruction', '')
inp = row.get('input', '')
if inp:
prompt = f'{prompt}\n\nInput: {inp}'
response = row.get('output', '')
if prompt and response:
f.write(json.dumps({'prompt': prompt, 'response': response}, ensure_ascii=False) + '\n')
count += 1
if count >= 10000:
break
print(f'Code: {count} rows')
"
# -- 3.1.5 Thinking pattern conversion
python -c "
import json
# Convert GSM8K step-by-step into <think> format
with open('data/pipeline/math_gsm8k_sft.jsonl') as f_in, \
open('data/pipeline/thinking_sft.jsonl', 'w') as f_out:
count = 0
for line in f_in:
row = json.loads(line)
answer = row['response']
parts = answer.rsplit('####', 1)
if len(parts) == 2:
reasoning = parts[0].strip()
final = parts[1].strip()
response = f'<think>\nLet me work through this step by step.\n{reasoning}\n</think>\nThe answer is {final}.'
else:
response = f'<think>\n{answer}\n</think>\n{answer}'
f_out.write(json.dumps({
'prompt': row['prompt'].replace('step by step', 'step by step. Show your reasoning in <think> tags'),
'response': response,
}, ensure_ascii=False) + '\n')
count += 1
print(f'Thinking: {count} rows')
"
# -- 3.1.6 Merge + Shuffle
cat data/pipeline/ko_sft.jsonl \
data/pipeline/math_gsm8k_sft.jsonl \
data/pipeline/math_competition_sft.jsonl \
data/pipeline/code_sft.jsonl \
data/pipeline/thinking_sft.jsonl \
| shuf --random-source=<(yes 42) > data/pipeline/all_sft_raw.jsonl
wc -l data/pipeline/all_sft_raw.jsonl
# Expected: ~40K rows
3.2 DPO Data -- 3 Preference Axes
mkdir -p data/pipeline
# -- Math correct/incorrect preference
python -c "
from datasets import load_dataset
import json, random
random.seed(42)
ds = load_dataset('gsm8k', 'main', split='train')
with open('data/pipeline/math_dpo.jsonl', 'w') as f:
for row in ds:
prompt = f'Solve: {row[\"question\"]}'
chosen = row['answer']
parts = chosen.rsplit('####', 1)
if len(parts) == 2:
wrong = random.randint(1, 9999)
rejected = parts[0] + f'#### {wrong}'
else:
rejected = 'I am not sure about this problem.'
f.write(json.dumps({
'prompt': prompt, 'chosen': chosen, 'rejected': rejected
}, ensure_ascii=False) + '\n')
print(f'Math DPO: {len(ds)} rows')
"
# -- Korean preference (already available)
cp data/dpo_10k_raw.jsonl data/pipeline/ko_dpo.jsonl
# -- Thinking preference (with thinking > without thinking)
python -c "
import json
with open('data/pipeline/thinking_sft.jsonl') as f_think, \
open('data/pipeline/math_gsm8k_sft.jsonl') as f_plain, \
open('data/pipeline/thinking_dpo.jsonl', 'w') as f_out:
think_data = {json.loads(l)['prompt']: json.loads(l)['response'] for l in f_think}
count = 0
for line in f_plain:
row = json.loads(line)
# Find if a thinking version exists
for tp, tr in think_data.items():
if row['prompt'][:50] in tp:
f_out.write(json.dumps({
'prompt': tp,
'chosen': tr,
'rejected': row['response'],
}, ensure_ascii=False) + '\n')
count += 1
break
if count >= 5000:
break
print(f'Thinking DPO: {count} rows')
"
# -- Merge
cat data/pipeline/math_dpo.jsonl \
data/pipeline/ko_dpo.jsonl \
data/pipeline/thinking_dpo.jsonl \
| shuf --random-source=<(yes 42) > data/pipeline/all_dpo_raw.jsonl
wc -l data/pipeline/all_dpo_raw.jsonl
3.3 RM/PPO Data
RM reuses the DPO data, and PPO only requires prompts.
# RM: Use DPO data directly
cp data/pipeline/all_dpo_raw.jsonl data/pipeline/rm_raw.jsonl
# PPO: Extract prompts only
python -c "
import json
seen = set()
with open('data/pipeline/all_sft_raw.jsonl') as f_in, \
open('data/pipeline/ppo_prompts_raw.jsonl', 'w') as f_out:
count = 0
for line in f_in:
row = json.loads(line)
p = row['prompt'][:200]
if p not in seen:
seen.add(p)
f_out.write(json.dumps({'prompt': row['prompt']}, ensure_ascii=False) + '\n')
count += 1
if count >= 5000:
break
print(f'PPO prompts: {count}')
"
4. Phase 2: MoE SFT -- Multi-Domain Foundation
Strategy: moe_expert_lora (4 experts) -- expecting per-expert domain specialization
eulerforge train \
--preset configs/presets/qwen3.5_0.8b_moe_expert_lora_sft.yml \
--set data.format=raw \
--set data.task=sft \
--set data.path=data/pipeline/all_sft_raw.jsonl \
--set data.max_length=1024 \
--set training.max_train_steps=15000 \
--set training.batch_size=2 \
--set training.grad_accum_steps=8 \
--set training.log_steps=100 \
--set training.save_steps=3000 \
--output-dir outputs/pipeline/01_sft
Estimated time: ~6-8 hours (RTX 5090, ~40K data, 15K micro-steps)
3-Phase Schedule: - Phase 0 (step 0-2000): Router warmup -- learn expert routing patterns - Phase 1 (step 2000+): LoRA training -- per-domain expert specialization
Checkpoints:
- In metrics.jsonl, loss should decrease steeply until step 5000
- Converge below 2.0 after step 10000
- Check expert utilization balance via MoE routing stats (advanced metrics)
5. Phase 3: Preference Alignment -- DPO or ORPO
Option A: DPO (uses reference model)
eulerforge train \
--preset configs/presets/qwen3.5_0.8b_moe_expert_lora_dpo.yml \
--set model_name=outputs/pipeline/01_sft/final \
--set data.format=raw \
--set data.task=prompted_preference \
--set data.path=data/pipeline/all_dpo_raw.jsonl \
--set data.max_length=1024 \
--set training.max_train_steps=6000 \
--set training.batch_size=2 \
--set training.grad_accum_steps=8 \
--output-dir outputs/pipeline/02_dpo
Option B: ORPO (DPO alternative -- recommended for single GPU)
Advantages over DPO: 50% memory savings (1x forward), Handoff compatible, combines SFT+preference Disadvantages vs DPO: Without reference comparison, preference boundaries may be slightly less distinct
eulerforge train \
--preset configs/presets/qwen3.5_0.8b_dense_lora_orpo.yml \
--set model_name=outputs/pipeline/01_sft/final \
--set injection.strategy=moe_expert_lora \
--set injection.num_experts=4 --set injection.top_k=2 \
--set data.format=raw \
--set data.task=preference \
--set data.path=data/pipeline/all_dpo_raw.jsonl \
--set data.max_length=1024 \
--set training.max_train_steps=6000 \
--output-dir outputs/pipeline/02_orpo
Estimated time: ~4-6 hours (DPO), ~3-4 hours (ORPO)
DPO Checkpoints:
- Phase 0 (router): reward_margin=0, loss=0.6931 (normal -- policy=reference when LoRA is frozen)
- Phase 1 (LoRA): reward_margin gradually positive, accuracy > 0.5
ORPO Checkpoints:
- Phase 0: sft_loss starts decreasing (ORPO includes SFT loss, so it is effective even during router phase)
- Phase 1: orpo_loss decreases + log_odds_ratio increases positively
6. Phase 4: RM -- Reward Model Training
eulerforge train \
--preset configs/presets/qwen3.5_0.8b_dense_lora_rm.yml \
--set model_name=outputs/pipeline/01_sft/final \
--set data.format=raw \
--set data.task=preference \
--set data.path=data/pipeline/rm_raw.jsonl \
--set data.max_length=1024 \
--set training.max_train_steps=6000 \
--output-dir outputs/pipeline/03_rm
Estimated time: ~3-4 hours
Note:
dense_lorastrategy is recommended for RM -- MoE routing instability can negatively affect reward prediction.
Checkpoints:
- RM accuracy > 0.65 (rate at which chosen scores higher than rejected)
- Verify that reward_head.pt is saved in final/
7. Phase 5: PPO -- RLHF Policy Optimization
eulerforge train \
--preset configs/presets/qwen3.5_0.8b_dense_lora_ppo.yml \
--set model_name=outputs/pipeline/02_dpo/final \
--set training.reward_model.checkpoint_path=outputs/pipeline/03_rm/final \
--set data.format=raw \
--set data.task=sft \
--set data.path=data/pipeline/ppo_prompts_raw.jsonl \
--set data.max_length=512 \
--set training.max_train_steps=3000 \
--output-dir outputs/pipeline/04_ppo
Estimated time: ~4-6 hours
Checkpoints:
- reward_mean gradually increases
- Verify that KL divergence is not too large (prevent excessive reward hacking)
8. Phase 6: Benchmark -- 4-Stage Comparison
8.1 Math Ability Comparison
# configs/bench/pipeline_math.yml
bench:
task: sft
data_path: data/pipeline/math_gsm8k_sft.jsonl
sample:
k: 20
seed: 42
generation:
max_new_tokens: 512
temperature: 0.1
models:
target:
model_dir: "outputs/pipeline/04_ppo/final"
device: "cuda:0"
baseline:
enabled: true
provider: ollama
model: "qwen3.5:0.8b"
judge:
enabled: true
provider: ollama
model: "gpt-oss:20b"
mode: pointwise
output:
out_dir: outputs/bench_pipeline_math
save_jsonl: true
print_examples: true
eulerforge bench --preset configs/bench/pipeline_math.yml
8.2 Korean Conversation Comparison
eulerforge bench --preset configs/bench/pipeline_math.yml \
--set bench.data_path=data/sft_1k_bench_raw.jsonl \
--set bench.generation.temperature=0.7 \
--set bench.generation.max_new_tokens=400 \
--set bench.output.out_dir=outputs/bench_pipeline_ko
8.3 Stage-by-Stage Comparison (SFT -> DPO -> PPO Evolution Tracking)
# SFT model
eulerforge bench --preset configs/bench/pipeline_math.yml \
--set bench.models.target.model_dir=outputs/pipeline/01_sft/final \
--set bench.output.out_dir=outputs/bench_pipeline_sft
# DPO model
eulerforge bench --preset configs/bench/pipeline_math.yml \
--set bench.models.target.model_dir=outputs/pipeline/02_dpo/final \
--set bench.output.out_dir=outputs/bench_pipeline_dpo
# PPO model (final)
eulerforge bench --preset configs/bench/pipeline_math.yml \
--set bench.models.target.model_dir=outputs/pipeline/04_ppo/final \
--set bench.output.out_dir=outputs/bench_pipeline_ppo
9. Expected Results Analysis
9.1 Capability Evolution by Stage
| Stage | Math Accuracy | Thinking Rate | Korean Quality | Preference Alignment |
|---|---|---|---|---|
| Original (Qwen3.5-0.8B) | Low | 0% | Basic | None |
| SFT | Medium | ~30% | Good | None |
| DPO | Medium-High | ~60% | Good | Initial |
| PPO | High | ~70%+ | Good | Reinforced |
9.2 Verifying MoE Expert Specialization
Training with --metrics-level advanced allows you to check expert routing statistics:
[MoE Routing] Layer 5: expert_0=32%, expert_1=28%, expert_2=22%, expert_3=18%
Ideally: - Expert 0: Math/reasoning (high rate for numeric-heavy inputs) - Expert 1: Coding (high rate for code tokens) - Expert 2: Korean (high rate for Korean tokens) - Expert 3: General/English
Whether this level of specialization naturally emerges depends on the data mixing ratio and training duration. Observing and analyzing this is itself the core learning point of this lab.
10. Advanced Variant Experiments
10.1 LoRA Handoff
Applying LoRA Handoff during the SFT stage transfers knowledge from LoRA to the base FFN in the later phase:
training:
lora_handoff:
expert_lora:
start_step: 8000
duration_steps: 5000
end_scale: 0.0
curve: cosine
end_action: freeze
base_ffn_ramp:
start_step: 8000
end_step: 13000
start_multiplier: 1.0
end_multiplier: 3.0
10.2 Strategy Comparison: dense_lora vs moe_expert_lora
Compare by changing only the strategy on the same data:
- dense_lora: Simple LoRA, all parameters as a single expert
- moe_expert_lora: 4 experts, per-domain specialization possible
10.3 Data Ratio Experiments
Explore the optimal ratio by varying domain mixing proportions: - Math heavy: 50% math + 50% rest - Coding heavy: 50% coding + 50% rest - Balanced: 25% each
11. Checkpoint Chain Summary
data/pipeline/
├── all_sft_raw.jsonl # ~40K (4-domain mix)
├── all_dpo_raw.jsonl # ~23K (3 preference axes)
├── rm_raw.jsonl # = all_dpo_raw.jsonl
└── ppo_prompts_raw.jsonl # ~5K (prompts only)
outputs/pipeline/
├── 01_sft/final/ # MoE SFT completed model
├── 02_dpo/final/ # MoE DPO aligned model
├── 03_rm/final/ # Reward Model + reward_head.pt
│ └── reward_head.pt
├── 04_ppo/final/ # Final RLHF model ★
└── bench_pipeline_*/ # Benchmark results for each stage
12. What You Will Learn from This Lab
| Learning Point | Content |
|---|---|
| Data engineering | Multi-domain data collection/conversion/mixing/shuffling |
| Significance of training order | How each stage (SFT -> DPO -> RM -> PPO) changes the model |
| Understanding MoE behavior | Expert routing, specialization, load balance |
| Phase scheduling | The rationale behind router warmup -> LoRA -> full unfreeze order |
| Limitations of preference alignment | Whether DPO alone is sufficient; the value PPO adds |
| Benchmark interpretation | The meaning and limitations of pointwise scores |
| Hands-on with latest trends | Thinking patterns, MoE specialization, progressive alignment |
Related Documents
- 18_training_pipeline.md -- Training order overview
- 03_moe_expert_lora.md -- MoE Expert LoRA details
- 05_dpo_training.md -- DPO details
- 07_rm_training.md -- Reward Model details
- 08_ppo_training.md -- PPO details
- 14_lora_handoff.md -- LoRA Handoff details
- 21_lab_thinking_model.md -- Thinking model lab