1. 概要
本記事は、東京大学 松尾・岩澤研究室 LLM開発コンペ2025 に参加して得られた知見をまとめたものです。コンペ期間中は、GSPOによってQLoRAを上回る有効性を示すことができなかったため、コンペ終了後に医療データを用いた追加実験を行いました。
結果として、ベースモデルを上回る性能向上は得られませんでしたが、Qwen3-235B に対して GSPO / GRPO を適用した事例はまだ多くなく、今後同様の検証を行う方の参考になればと思い、記録として残します。
2. 背景
コンペ期間中には、GSM8K データセットを用いて LLaMA 系の小規模モデルに対し PPO / GRPO / GSPO を適用し、比較評価を行いました。その結果、GSPO は短いトレーニング期間で最も高い性能に到達するという事前検証結果が得られています。
一方で、Qwen3-235B のような大規模モデルに対して GSPO / GRPO を適用することは、主にメモリ使用量の観点から難易度が高く、コンペ期間中に安定して学習を回すことができなかったため、実験を断念しました。
コンペ終了後、結果発表までに約2週間の期間があったため、その期間を利用して Qwen3-235B に対する GSPO / GRPO の適用を試みました。
3. 事前検証の結果
GSM8K データセットを用いて、LLaMA 系の小規模モデルに対して PPO / GRPO / GSPO を適用した際の結果を示します。
報酬の推移を見ると、GSPO は PPO や GRPO と比較して、より短い学習ステップで高い報酬に到達していることが分かります。

図1: GSM8KにおけるPPO / GRPO / GSPOの報酬推移
verlの環境が構築済みであることを前提に、以下の設定で学習を実行しました。
GSPOのサンプルコード
cat > run_gspo.sh << 'EOF'
#!/bin/bash
# GSPO Training Script
# プロジェクトパスの設定
export PROJECT_BASE="$PROJECT_PATH"
export MODEL_BASE="$PROJECT_BASE/models/base"
export DATA_BASE="$PROJECT_BASE/data/raw"
export MY_EXP_BASE="$HOME/experiments"
# 警告を抑制
export PYTHONWARNINGS="ignore::UserWarning"
# WandB設定(変更してください)
export WANDB_ENTITY="YOUR_WANDB_ENTITY"
export WANDB_PROJECT_NAME="YOUR_WANDB_PROJECT_NAME"
export WANDB_RUN_NAME="YOUR_RUN_NAME_gspo_$(date +%Y%m%d_%H%M%S)"
echo "=== GSPO Training Configuration ==="
echo "Base Model: $MODEL_BASE/Llama-3.2-1B-Instruct"
echo "Data: $DATA_BASE/gsm8k"
echo "Output: $MY_EXP_BASE/gspo/checkpoints"
echo "=================================="
# データファイルの存在確認
if [ ! -f "$DATA_BASE/gsm8k/train.parquet" ]; then
echo "Error: Training data not found at $DATA_BASE/gsm8k/train.parquet"
echo "Please run data preprocessing first."
exit 1
fi
PYTHONUNBUFFERED=1 python -m verl.trainer.main_ppo \
algorithm.adv_estimator=grpo \
actor_rollout_ref.actor.policy_loss.loss_mode=gspo \
actor_rollout_ref.actor.loss_agg_mode=seq-mean-token-mean \
data.train_files="$DATA_BASE/gsm8k/train.parquet" \
data.val_files="$DATA_BASE/gsm8k/test.parquet" \
data.train_batch_size=512 \
data.max_prompt_length=512 \
data.max_response_length=256 \
data.dataloader_num_workers=0 \
data.shuffle=true \
actor_rollout_ref.rollout.name=vllm \
actor_rollout_ref.rollout.mode=sync \
actor_rollout_ref.model.path="$MODEL_BASE/Llama-3.2-1B-Instruct" \
+actor_rollout_ref.model.dtype=bfloat16 \
actor_rollout_ref.model.use_remove_padding=true \
actor_rollout_ref.actor.optim.lr=1e-6 \
actor_rollout_ref.actor.ppo_mini_batch_size=128 \
actor_rollout_ref.actor.ppo_micro_batch_size_per_gpu=8 \
actor_rollout_ref.actor.use_kl_loss=false \
actor_rollout_ref.actor.kl_loss_coef=0.0 \
actor_rollout_ref.actor.entropy_coeff=0 \
actor_rollout_ref.actor.clip_ratio_low=0.0003 \
actor_rollout_ref.actor.clip_ratio_high=0.0004 \
actor_rollout_ref.actor.use_dynamic_bsz=true \
actor_rollout_ref.actor.entropy_checkpointing=true \
actor_rollout_ref.rollout.n=16 \
actor_rollout_ref.rollout.log_prob_micro_batch_size_per_gpu=16 \
actor_rollout_ref.rollout.tensor_model_parallel_size=1 \
actor_rollout_ref.rollout.gpu_memory_utilization=0.8 \
actor_rollout_ref.rollout.temperature=1.0 \
actor_rollout_ref.rollout.top_p=1.0 \
actor_rollout_ref.rollout.top_k=-1 \
actor_rollout_ref.ref.log_prob_micro_batch_size_per_gpu=8 \
algorithm.use_kl_in_reward=false \
reward_model.reward_manager=dapo \
+reward_model.reward_kwargs.overlong_buffer_cfg.enable=false \
+reward_model.reward_kwargs.overlong_buffer_cfg.len=128 \
+reward_model.reward_kwargs.overlong_buffer_cfg.penalty_factor=1.0 \
+reward_model.reward_kwargs.overlong_buffer_cfg.log=false \
+reward_model.reward_kwargs.max_resp_len=256 \
trainer.critic_warmup=0 \
trainer.logger="['console','wandb']" \
trainer.val_before_train=false \
trainer.n_gpus_per_node=8 \
trainer.nnodes=1 \
trainer.save_freq=10 \
trainer.test_freq=10 \
trainer.default_local_dir="$MY_EXP_BASE/gspo/checkpoints" \
trainer.project_name="$WANDB_PROJECT_NAME" \
trainer.experiment_name="$WANDB_RUN_NAME" \
trainer.total_epochs=10 \
trainer.total_training_steps=500 \
2>&1 | tee "gspo_training_$(date +%Y%m%d_%H%M%S).log"
EOF
chmod +x run_gspo.sh
4. 実験設定
Qwen3-235B Thinking をフルパラメータで強化学習(RL)する場合、少なくとも十数ノード規模の計算リソースが必要になります。また、LoRA を用いた場合であっても、実用的なバッチサイズと安定した学習を確保するには、最低でも 8 ノード程度は必要になると見積もられます。
本コンペにおいてチームに割り当てられていた計算リソースは、予選で 3 ノード、決勝で 8 ノードであり、235B 級のモデルをフルスペックで RL するには非常に制約の大きい環境でした。
そのため、限られたリソース下でも学習を成立させる方法として、4bit 量子化と LoRA を併用する構成を採用しました。
また、4bit 量子化を用いた RL に対応しているフレームワークとして、今回は ms-swift を使用しました。ms-swift は Qwen 系モデルと同じく Alibaba 系のエコシステムで開発されており、Qwen 系モデルとの相性が比較的良い可能性があると考え、今回の検証ではこれを採用しました。
使用したスクリプト
#!/usr/bin/env python3
"""
QLoRA+GSPO MedMCQA訓練スクリプト
Qwen3-235B-A22B-Thinkingモデルを医療多肢選択問題で訓練
"""
import os
import json
import re
from swift.llm import RLHFArguments, rlhf_main
from datasets import load_dataset
class MedMCQARewardFunction:
"""MedMCQA用の報酬関数"""
# 末尾の文字数制限(解答は通常最後に来るため)
ANSWER_CLIP_CHARS = 300
def __init__(self, dataset):
"""正解をデータセットから抽出"""
self.ground_truths = []
for item in dataset:
# MedMCQAの正解は'cop'フィールド(1-4の数字)
correct_option = item.get('cop', 0)
if 1 <= correct_option <= 4:
# 1-4 を A-D に変換
correct_choice = chr(ord('A') + correct_option - 1)
self.ground_truths.append(correct_choice)
else:
self.ground_truths.append('')
def __call__(self, completions, **kwargs):
"""各生成に対して報酬を計算"""
rewards = []
indices = kwargs.get('indices', list(range(len(completions))))
for i, completion in enumerate(completions):
idx = indices[i] if i < len(indices) else i
ground_truth = self.ground_truths[idx] if idx < len(self.ground_truths) else ''
reward = self._compute_score(
str(completion),
ground_truth,
method='strict',
format_score=0.1, # フォーマットは正しいが不正解
score=1.0 # 正解
)
rewards.append(reward)
# デバッグ出力(最初の10個)
if i < 10:
answer = self._extract_answer(str(completion), method='strict')
print(f"\n[Debug {i}] 正解: {ground_truth} | 抽出答案: {answer} | 報酬: {reward:.1f}")
return rewards
def _extract_answer(self, completion, method='strict'):
"""解答を抽出(GSM8K方式を参考)"""
# 最適化:長い文字列での正規表現は遅いので、末尾のみをチェック
if len(completion) > self.ANSWER_CLIP_CHARS:
completion = completion[-self.ANSWER_CLIP_CHARS:]
if method == 'strict':
# 厳密なフォーマットチェック
# 複数のパターンを優先順位付きで試す
patterns = [
r'答え[::]\s*([A-D])\b', # 日本語
r'[Aa]nswer[::]\s*([A-D])\b', # 英語
r'正解[::]\s*([A-D])\b', # 日本語別表現
r'[Tt]he\s+correct\s+answer\s+is\s*[::]?\s*([A-D])\b', # 英語長形式
r'\b([A-D])\s+is\s+the\s+correct\s+answer', # 英語逆順
r'選択肢\s*([A-D])\b', # 選択肢
r'Therefore,?\s+([A-D])\b', # Therefore結論
r'So,?\s+([A-D])\b', # So結論
r'最終的な答えは\s*([A-D])', # 最終的な答え
r'Final\s+answer[::]\s*([A-D])\b', # Final answer
]
for pattern in patterns:
matches = re.findall(pattern, completion, re.IGNORECASE)
if matches:
# 最後のマッチを返す
return matches[-1].upper()
return None
elif method == 'flexible':
# より柔軟な抽出(単独のA-Dを探す)
matches = re.findall(r'\b([A-D])\b', completion)
if matches:
# 最後の選択肢を返す
return matches[-1]
return None
def _compute_score(self, completion, ground_truth, method='strict', format_score=0.1, score=1.0):
"""スコア計算(GSM8K方式)
Args:
completion: 生成されたテキスト
ground_truth: 正解(A, B, C, D)
method: 'strict' または 'flexible'
format_score: フォーマットは正しいが不正解の場合のスコア
score: 正解の場合のスコア
"""
answer = self._extract_answer(completion, method=method)
if answer is None:
# 解答が抽出できない場合
return 0.0
else:
if answer == ground_truth:
# 正解
return score
else:
# フォーマットは正しいが不正解
return format_score
def prepare_medmcqa_dataset(dataset_path='data/medmcqa_train.jsonl'):
"""MedMCQAデータセットの準備"""
# HuggingFaceからデータセットをロード
dataset = load_dataset("openlifescienceai/medmcqa", split="train[:1000]") # 最初の1000件
# JSONL形式で保存
os.makedirs(os.path.dirname(dataset_path), exist_ok=True)
with open(dataset_path, 'w', encoding='utf-8') as f:
for item in dataset:
# 必要なフィールドを抽出
formatted_item = {
'question': item['question'],
'opa': item['opa'],
'opb': item['opb'],
'opc': item['opc'],
'opd': item['opd'],
'cop': item['cop'], # correct option (1-4)
'exp': item.get('exp', ''), # explanation if available
'subject_name': item.get('subject_name', ''),
'topic_name': item.get('topic_name', '')
}
f.write(json.dumps(formatted_item, ensure_ascii=False) + '\n')
return dataset_path
def create_training_script():
"""訓練実行スクリプトの作成"""
script_content = '''#!/bin/bash
export CUDA_VISIBLE_DEVICES=0,1,2,3,4,5,6,7
export PYTHONPATH="${PYTHONPATH}:$(pwd)"
# チェックポイント確認
CHECKPOINT_DIR=""
if [ -d "outputs/gspo_medmcqa_2000_50steps" ]; then
LATEST_CHECKPOINT=$(ls -d outputs/gspo_medmcqa_2000_50steps/*/checkpoint-* 2>/dev/null | sort -V | tail -n 1)
if [ -n "$LATEST_CHECKPOINT" ]; then
CHECKPOINT_DIR="$LATEST_CHECKPOINT"
echo "Found checkpoint: $CHECKPOINT_DIR"
fi
fi
# メインの訓練スクリプトを実行
export CHECKPOINT_DIR="$CHECKPOINT_DIR"
python medmcqa_gspo_training.py
'''
with open('run_medmcqa_gspo.sh', 'w') as f:
f.write(script_content)
os.chmod('run_medmcqa_gspo.sh', 0o755)
def main():
"""メイン訓練関数"""
# データセット準備
dataset_path = 'data/medmcqa_train.jsonl'
if not os.path.exists(dataset_path):
print("MedMCQAデータセットを準備中...")
dataset_path = prepare_medmcqa_dataset(dataset_path)
# データセット読み込み
with open(dataset_path, 'r', encoding='utf-8') as f:
dataset = [json.loads(line) for line in f]
# 報酬関数の初期化
reward_function = MedMCQARewardFunction(dataset)
# チェックポイントの確認
checkpoint_dir = os.environ.get('CHECKPOINT_DIR', '')
resume_from_checkpoint = checkpoint_dir if checkpoint_dir else None
# 訓練引数の設定
args = RLHFArguments(
rlhf_type='grpo',
model='/nvme34/Qwen/Qwen3-235B-A22B-Thinking-2507',
dataset=dataset_path,
output_dir='outputs/gspo_medmcqa_2000_50steps',
resume_from_checkpoint=resume_from_checkpoint,
# LoRA設定
train_type='lora',
lora_rank=8,
lora_alpha=16,
target_modules=['q_proj', 'v_proj', 'o_proj', 'k_proj'],
# 量子化設定
quant_method='bnb',
quant_bits=4,
bnb_4bit_quant_type='nf4',
bnb_4bit_use_double_quant=True,
# バッチサイズ設定
per_device_train_batch_size=1,
gradient_accumulation_steps=4,
# GSPO固有パラメータ
num_generations=2,
steps_per_generation=2,
importance_sampling_level='sequence',
epsilon=3e-4,
epsilon_high=4e-4,
beta=0.0, # KLペナルティなし
# トークン長設定(MedMCQA用に短縮)
max_length=2500, # 入力+出力の合計
max_completion_length=2000, # 出力の最大長
temperature=0.7,
top_p=0.9,
# 訓練パラメータ
max_steps=50,
learning_rate=1e-5,
warmup_ratio=0.05,
weight_decay=0.01,
# ログとチェックポイント
logging_steps=1,
save_steps=1,
save_only_model=True,
save_total_limit=None,
# 報酬関数
reward_funcs=[reward_function],
reward_weights=[1.0],
# その他の設定
gradient_checkpointing=True,
bf16=True,
torch_empty_cache_steps=5,
# テンプレートとプロンプト
template='qwen3_thinking',
system="""You are a medical AI assistant solving multiple choice questions.
Instructions:
1. Read the question and all four options (A, B, C, D) carefully
2. Think through the medical concepts and reasoning step by step
3. Consider each option and eliminate incorrect ones
4. Provide your final answer in EXACTLY one of these formats:
- "Answer: [A/B/C/D]"
- "答え: [A/B/C/D]"
- "The correct answer is [A/B/C/D]"
- "Final answer: [A/B/C/D]"
Important: You MUST include your reasoning process before giving the final answer. The answer format must appear at the end of your response.""",
seed=42,
)
# 訓練情報の表示
print("="*80)
print("MedMCQA GSPO Training Configuration")
print("="*80)
print(f"- Dataset: MedMCQA (Medical Multiple Choice)")
print(f"- Max completion length: 2000 tokens")
print(f"- Total steps: 50")
print(f"- Reward function: Format-aware MCQ scoring")
print(f"- Resume capability: {'Yes (from ' + resume_from_checkpoint + ')' if resume_from_checkpoint else 'No (fresh start)'}")
print("="*80)
# 訓練開始
rlhf_main(args)
if __name__ == "__main__":
# 実行スクリプトの作成
create_training_script()
# メイン訓練の実行
main()
5. 報酬関数
前述の通り、使用可能な計算リソースが限られているため、報酬モデル(Reward Model)を使用する構成は採らず、低コストで計算可能なルールベースの報酬関数を定義しました。
今回使用した報酬関数は、以下のようなシンプルな Sparse reward です。
- 最終的な選択肢が正解と一致しているか
- かつ、指定したフォーマットで解答が出力されているか
正解かつフォーマットが正しい場合に最大報酬を与え、フォーマットは正しいが不正解の場合には小さな報酬を与える、という設計にしています。
6. 結果
Qwen3-235B に対して GSPO / GRPO を適用した結果、ベースモデルを上回る性能向上は確認できませんでした。評価結果を以下に示します。
| モデル | MedexpertQA 正答率 |
|---|---|
| ベースモデル | 37.27% |
| GSPO | 36.12% |
報酬獲得は動作しているように見えますが、最終的な評価指標である MedExpertQA の正答率には明確な改善は見られませんでした。
7. 所感
今回の結果から、Qwen3-235B に対して GSPO を適用すること自体は技術的に可能であると確認できましたが、最終的な性能向上には至りませんでした。
振り返ってみると、最も大きかったのは、学習データの「質」、すなわち評価指標(MedExpertQA)に対する適合性だったように思います。
今回用いた MedMCQA の一部データという学習設定自体が、MedExpertQA のスコアを直接的に押し上げるようなタスク構成・分布になっておらず、モデルの学習方向が評価タスクの改善とうまく噛み合っていなかった可能性があります。その結果、「学習は進んでいるが、評価したい能力とは別の方向に最適化されている」状態になっていたとも解釈できます。
また、今回用いた報酬関数は最終的な選択肢の正誤とフォーマットのみを見るルールベースのものであり、推論過程や中間ステップの良し悪しは一切評価しない設計でした。そのため、タスク構造に対して十分に情報量のあるフィードバックを与えられていなかった可能性もあります。
今回の結果は、「報酬設計そのものが本質的に不適切だった」というよりも、評価タスクと学習タスクの対応関係、すなわちデータ側の設計が十分ではなかったことの影響が大きかったように思います。
経験的には、80B 程度までのモデルサイズであれば、強化学習は比較的安定して回るケースをこれまでに確認できていますが、235B クラスになると、最適化の難易度がさらに一段上がる、という印象を強く受けました。
8.謝辞
最後に、本コンペにおいて多くの議論と検証を共にしていただいた oNo-1 チームの皆様、および参謀チームの皆様に心より感謝いたします。
特に、リーダーとしてチームを牽引し、最後まで支えてくださった小野さんには、深く感謝いたします。
本当にありがとうございました。
本プロジェクトは、国立研究開発法人新エネルギー・産業技術総合開発機構(以下「NEDO」)の「日本語版医療特化型LLMの社会実装に向けた安全性検証・実証」における基盤モデルの開発プロジェクトの一環として行われます。
