DeepSeekにおける強化学習の手法を用いてLLMにRPG風ゲームの攻略法を説明させてみた
今回は、簡単なゲームを設定して、DeepSeekで提案された強化学習の手法であるGRPOを適用し、このゲームの攻略法を説明できるLLMを作ってみました。
DeepSeekってタイトルに入れてバズらせようとして、脳死でGRPOを使いましたが、今回の目的と相性が良く、GRPOの素晴らしさも知ることができました。
先に言っておきますが、そんなに壮大なゲームではなく、私が考えた簡単なゲームで実験しましたので、そこは期待しないでください。また、タイトルにもある通りあんまりうまくいってません。そもそもこの手法に限界がある気もしており、そちらについても考察しています。
Google Colabで実行できるコードもありますので、そちらを参照しながら読み進めていただけると幸いです。(Google Colabリンク)
先行研究の調査
DeepSeek
この記事が分かりやすすぎますので、こちらを読んでください。特にGRPOについての解説。
DeepSeek以外 (LLMでゲームを解いてる研究)
(別にこれらの研究を前提にしているわけではないので、読み飛ばしても大丈夫です🙆♂️)
かなり雑に似たような研究がないか調査しました。今回の手法と近い研究を2例発見したので紹介します。
Process Reward Models for LLM Agents:Practical Framework and Directions
参考)という自然言語でプレイ可能なゲームを言語モデルに攻略させる論文です。以下の図の流れでモデルを学習しています。
- Rolelout and Compute Targets
最初にLLMに何度もゲームをプレイさせて、その行動履歴とゲーム結果(クリアか失敗か)を獲得します。ゲーム結果から、各状態と行動における行動価値を以下の式で計算します。
Q^\pi(s_t, a_t) = \mathbb{E}\pi\left[\sum_{k=t}^{T} \gamma^{k-t}r(s_k, a_k)\middle|s_t, a_t\right] - Train Process Reward Models
1.で獲得したデータセットから行動価値を計算する関数を近似します。以下の式が目的関数。
L(Q_\phi) = -\mathbb{E}_{(s,a,\hat{Q}) \sim {D}} \left[ \hat{Q} \log Q_\phi(s,a) + (1 - \hat{Q}) \log(1 - Q_\phi(s,a)) \right] - Train Policy via RL
ここで、強化学習の登場。2.で計算した行動価値関数を使ってポリシー(LLM)を更新します。
\pi_i = \arg\max_{\pi_\theta} \, \mathbb{E}_{s \sim D,\, a \sim \pi_\theta(a|s)} \left[ Q_\phi(s, a) \right] - \beta \, D_{\mathrm{KL}} \left( \pi_\theta(a|s) \, \| \, \pi_{i-1}(a|s) \right)
ポリシーを更新するたびにゲームプレイと行動価値関数の計算が必要になるため、計算量がかなり多そうという印象。
Teaching Large Language Models to Reason with Reinforcement Learning
こちらは、言語モデルを強化学習することによって、数学の問題の正答率が向上するか検証した実験。3つの手法を比較し、以下の手法の正答率が最も高かったと述べられています。
-
Expert Iteration (EI)
モデルが出力した回答から、正解に至った回答をフィルタリングして、それを教師データとして再学習するという手法。After generating K samples S₁,…,Sₖ on a question Q we construct D₁ by filtering all (Q, Sᵢ) pairs with return below a threshold T.
実験によると、データを集め→再学習を2回くりかえすと収束したとのことでした。
論文ではPPOによる更新も検証されていましたが、それよりも正答率が高くなったとのことでした。
LLMとゲームを繋げる
今回やったこととしては、簡単なゲームを設定し、先に純粋な強化学習によって行動価値関数を計算しておいて、これを報酬として強化学習をする。というだけのもの。簡単にやったことを図示しますと、以下のようになります。
取り扱うゲームとして、以下のようなゲームを設定しました。
ゲーム設定
- 初期状態に、health (今回は 12) が与えられ、行動を選択してgoldを最大化することが目的。
- round(行動回数)は5。
- 行動によってhealthを消費し、healthが0になった場合は、goldが自動的に0になる。(死亡)
- 行動は以下の3つから選択でき、効果は以下の通り。
- adventure: healthを1,2,3,4,5からランダムで消費し、10倍のgoldを得る。
- investment: 体力を3減らしてgoldを1.5倍にする。
- save: healthを1減らす。
あるエピソードの例
Round(1) : State(h=12, g=0) -> Action=adventure
Round(2) : State(h=9, g=30) -> Action=adventure
Round(3) : State(h=6, g=60) -> Action=investment
Round(4) : State(h=3, g=90) -> Action=save
Round(5) : State(h=2, g=90) -> Action=save
Final: h=1, g=90
強化学習によって行動価値関数を計算
こちらについては、前回の記事で解説しましたので、もし良ければご参照ください。
入力文を作成
純粋なゲームからの情報は、行動の履歴と、health・goldの推移のみ。これを言語モデルが理解しやすい形に落とし込みます。
ゲームの状況 (trajectory) を受け取って、言語モデルに入力する文章を作成する関数を作成しました。出力してほしい内容を明確に説明するように心がけました。
入力文としては以下の通り。後述しますが、指示文とゲームの状況のみを与えた場合、うまくいかなかったため、ヒントとなる計算を指示しています。次の状況について計算させることで、その計算を元に回答してくれることを期待しています。
You are a helpful AI for a text-based game.
Your goal is to amass as much Gold as possible, but if [Health]==0: [Gold]=0.
Your response **must** contain exactly three sections:
1.<Calculation>...</Calculation> - Please fill in next round prediction for all [Action]s (A, B, C)
[Action]={A,B,C} [NextGold]={Expected value of Gold} [NextHealth]={Expected value of Health} [DeathRisk]=([NumberOfRemainingActions]-1)/[NextHealth]
[DeathRisk] is capped at 1.0 it means ; if the formula exceeds 1.0 (e.g. because NextHealth ≤ 0), use 1.0 instead.
[DeathRisk] of 1.0 **means the player is guaranteed to die in a future turn**.
ex)[Action]=X [NextGold]=45 [NextHealth]=3 [DeathRisk]=0.33
2.<Reason>...</Reason> – Explain your reasoning based on the Calculation section.
3.<Action>X</Action> – Choose one best action (A, B, C)
You can choose from the following three actions, each with the described effect:
- A: [NextGold]=x*10+[Gold], [NextHealth]=[Health]-x (x={1,2,3,4,5|x is randomly chosen})
- C: [NextGold]=[Gold], [NextHealth]=[Health]-1
- B: [NextGold]=[Gold]*1.5, [NextHealth]=[Health]-3
**End the entire response immediately after the closing tag** – no period, newline, or space.
<Situation>
[NumberOfRemainingActions]=2
[Gold]=20
[Health]=3
</Situation>
<Calculation>
行動をアルファベットで表現する工夫について
上記の入力文を見てもらえるとわかりますが、前回の記事で行動に対して与えていた具体的な単語 (save, adventure, investment ) をアルファベットに置き換えました。というのも、具体的な単語で行動を選択させた場合、言語モデルが単語の意味自体にひっぱられる挙動を見せたからです。
具体的には、何をどう入力しても"Investment"の行動しか選ばなくなりました。たぶん、事前の知識に頼りすぎて、「goldを最大化!?」→「よっしゃ投資や!」みたいな思考に陥っていたのだと思います。
このような挙動を防ぐために、下のように置き換えました。このゲームにおいては、単語の意味がゲームに影響を与えることはありませんからね。
変更前 | 変更後 |
---|---|
Adventure | A |
Investment | B |
Save | C |
理想的な回答
以下の通り、計算部分のフォーマットを守ってもらい、理由説明も <Reason> タグの中で正しく行われています。
[Action]=B [NextGold]=30 [NextHealth]=4 [DeathRisk]=0.75
[Action]=A [NextGold]=20 [NextHealth]=4 [DeathRisk]=0.75
[Action]=C [NextGold]=20 [NextHealth]=6 [DeathRisk]=0.57
</Calculation>
<Reason>
B and A both reduce health to 4 with a moderate death risk of 0.75. A slight additional gold is obtained in B. C reduces health to 6 with a lower death risk of 0.57 but no gold increase.
</Reason>
<Action>C</Action>
ちなみに、強化学習なしで出力した場合は、以下のような感じ。
- 計算式に数字のみでなく文字列が入ってきてしまう。
- <Action> タグで回答を終了して欲しいのに、回答が続く。
などの誤りがあります。
誤った解答例
[Action]=A [NextGold]=30+[Gold] [NextHealth]=[Health]-2 [DeathRisk]=0.67
[Action]=B [NextGold]=30*[Gold] [NextHealth]=[Health]-3 [DeathRisk]=1.0
[Action]=C [NextGold]=[Gold] [NextHealth]=[Health]-1 [DeathRisk]=0.33
</Calculation>
<Reason>
For Action A, the expected gold gain is significant but the health loss is moderate. The death risk is relatively low, making this a balanced choice.
Action B offers a substantial gold increase but at a high health cost, leading to a guaranteed death due to the remaining actions.
Action C provides a small gold gain and a moderate health loss, making it the safest option despite the lower potential gold.
</Reason>
<Action>C</Action> Based on the calculations and reasoning provided, the safest and most prudent action given the current health and remaining actions is to take Action C. This minimizes the risk of immediate death while still allowing for a chance to accumulate more gold in subsequent turns. Therefore, the optimal choice is:
<Action>C</Action>
モデルの学習
さあ、いよいよ本題です。このモデルをいかにして賢くしていくか見ていきます。
使用したライブラリ
これを使いました。今もモリモリ更新されています。報酬関数を渡すとGRPOで学習してくれるスグレモノ。こういう最新技術を公開してくれるエンジニアたちに感謝です。
サンプラーをオーバーライド
入力文を学習データとしてモデルに渡してくれるsamplerクラスをオーバーライドして、ゲームに基づく入力文を渡してくれるようにします。行動価値関数が0でない状況から、ランダムに1つを選んで入力するようにしています。
from torch.utils.data import Dataset, DataLoader
from collections import Counter
import numpy as np
# カスタムDatasetの定義
class RuleBasedStateDataset(Dataset):
def __init__(self, length=200):
self.length = length # データセット長(エポック内のサンプル数)
def __len__(self):
return self.length
def __getitem__(self, idx):
# 1) 有効状態を 1 つランダム抽出
step_count, h, g = random.choice(valid_state_indices)
# 2) その状態から状態から文章を生成
text = generate_game_text_from_state(
step_count=step_count,
h_current=h,
g_current=g,
total_rounds=MAX_STEPS_PER_EPISODE
)
return {"prompt": text}
# データセットとSamplerの準備
dataset = RuleBasedStateDataset(length=200)
報酬関数の設計
これが超大事かと思います。ここで改めて、この強化学習によってLLMに学習させたい能力を確認します。
- フォーマットを守ること
- 計算部分(<Calculation>)に正しい答えを回答してくれること
- 正しい(報酬が高い)行動を選択してくれること
これらを強化学習で解決するように報酬関数を作成していきます。
計算部分(<Calculation>)に正しい答えを回答してくれること
上記に関しては、以下のような報酬関数を設定。コードを確認していただくとわかりますが、next_gold,next_health,death_risk はLLMの回答から次ターンについて計算した部分を抽出したもの。
actionがうまく取得できていない場合(A,B,C以外のアルファベット)は、やや大きめのマイナス報酬(罰則)を導入しています。コレによって正しいフォーマットを守らせることができるようになります。
また、healthやgoldやdeathriskを、ぞれぞれ同じくらい重要視して欲しいので、スケーリングして足し合わせた値を報酬として使っています。
def is_valid_transition(action: str,
remaining_action: int,
cur_gold: int,
cur_health: int,
next_gold: int,
next_health: int,
death_risk: float) -> bool:
"""
1 行の [NextGold] などがルール通りか検証。
action: 'A' adventure, 'B' investment, 'C' save
"""
if action == action_A:
nh = cur_health - 3 if cur_health > 3 else 0
ng_sum = 0
for i in range(1, 6):
if cur_health > i:
ng_sum = ng_sum + cur_gold + 10 * i
else:
ng_sum = ng_sum + 0
ng = ng_sum / 5
dr = remaining_action / nh if nh > 0 else 1.0
elif action == action_B:
nh = cur_health - 3 if cur_health > 3 else 0
ng = 0 if nh <= 0 else int(cur_gold * 1.5)
dr = remaining_action / nh if nh > 0 else 1.0
elif action == action_C:
nh = cur_health - 1
ng = 0 if nh <= 0 else cur_gold
dr = remaining_action / nh if nh > 0 else 1.0
else:
return -5
# 1.0 を超えた場合、必ず将来的に死亡するので上限をキャップする。
dr = 1.0 if dr > 1.0 else dr
# 正しい計算との差を確認する
reward = (
- abs(next_gold - ng) / 100
- abs(next_health - nh) / 10
- abs(death_risk - dr)
)
return reward
正しい(報酬が高い)行動を選択してくれること
以下のように計算しました。LLMの回答から行動部分を抜き出して、行動価値関数のテーブル(result_best)と照らし合わせて報酬を与える設計になっています。
ここでも、正しい回答が得られない場合。すなわち、うまくマッチングしない場合は、報酬から大きめの値を差し引いて返すようにしています。
def compute_q_reward(prompt, completion, **kwargs):
# <Calculation>・・・</Calculation>の計算が正しいかを確認する
results = check_calculation(prompt, completion)
status = {act: re for act, re in results}
reward = sum(status.get(action, -5) for action in ('A', 'B', 'C'))/3
# 1) <Reason>...</Reason> を含む部分をグループ1としてキャプチャ
# 2) <Action>(save|investment|adventure)</Action> をグループ2としてキャプチャ
# 行頭 ^ と行末 $ で全体を囲む
completion_pattern_str = r"(?s).*?<Reason>(.*?)</Reason>\s*<Action>({}|{}|{})</Action>$".format(action_A, action_B, action_C)
completion_pattern = re.compile(completion_pattern_str, re.DOTALL)
completion_match = completion_pattern.match(completion)
if not completion_match:
return reward - 1
# グループ1: <Reason>...</Reason> を含む部分
reason_section = completion_match.group(1)
# グループ2: Action タグに入る単語
action_word = completion_match.group(2)
# Action タグ内の単語に応じてインデックスなどを返したい場合
# (元のコードにあわせて idx を返すロジックにしています)
if action_word == action_A:
idx = 0
elif action_word == action_B:
idx = 1
elif action_word == action_C:
idx = 2
else:
return reward - 1
remaining_action, health, gold = extract_situation(prompt)
# result_best は上の方で求めた Q 学習テーブル
table = result_best[MAX_STEPS_PER_EPISODE-remaining_action, health, gold] # 形状: [..., n_actions]
total = table.sum()
eps = 1e-8 # ゼロ割り防止
norm_table = table / (total + eps) # 和が 1 になるよう正規化
return reward + norm_table[idx]
ハイパーパラメータ(など)の設定
以下のような値に設定して学習しました。
training_args = GRPOConfig(
output_dir="LoRAQwen_v10",
per_device_train_batch_size=8, # 極小のバッチサイズ
gradient_checkpointing=True, # 勾配チェックポイント有効化(メモリ節約)
#bf16=True, # FP16訓練を有効化 (または bf16=True)
max_prompt_length=400, # プロンプト長上限
max_completion_length=320, # 応答生成長上限
num_generations=8, # 各プロンプトで4件生成(メモリ負荷を抑える)
ds3_gather_for_generation=False, # ZeRO Stage 3で単一GPUに重み集約しない [oai_citation_attribution:40‡blog.csdn.net](https://blog.csdn.net/qq_36603091/article/details/145624763#:~:text=default%3DTrue%2C%20metadata%3D%7B%20,not%20compatible%20with%20vLLM%20generation)
learning_rate=1e-5,
save_steps=200,
logging_steps=5,
num_train_epochs=6,
beta=0.02,
temperature=1.0,
)
主に調整したパラメータが以下の2つ。
- beta
こちらはGRPOの更新式において、
- \beta D_{\text{KL}}(\pi_\theta ,|, \pi_{\text{ref}})
の部分。
更新されたモデルが、元のモデルの出力から離れすぎないようにする役割。
こいつを小さくしすぎると、途中から中国語しか出力しなくなったりしました。 - temperature
こちらは、LLMの出力の多様性を決める値。とにかくいろいろな出力をしてもらうことがまずは大事ということで、この値にしています。特に、パラメータをスイープするなどの実験はしていません。
学習の結果
以下に、各報酬関数の推移を示します。今回用いたライブラリでは、デフォルトでwandbに結果が出力されるようになっていました。
フォーマットが正しく、計算が正しい値ができるようになるまではマイナスになっていますが、だんだんと改善し、最終的にはプラスの報酬を得られるように改善されています。
reward推移
具体的な出力結果
トレーニング前後の出力結果を比較して、どのようにモデルが改善されたかを示したいと思います。学習前のモデル(base model)による出力と、学習後のモデル(trained model)を並べて示します。
事例その1
改善された点として、まず計算部分(<Calculation>タグ内)のフォーマットが挙げられます。また、学習後のモデルにおける理由説明も妥当性が増しており、最終的な答えもより期待値が高いC→Bへと改善されています。
ただし、行動Aの期待値が正しく計算できていません。これはどの回答でもそうなのですが、行動Aの場合の計算が複雑なのと、行動Aで死亡した場合の報酬が0になる結果に引っ張られて、全体的に正しい結果よりかなり小さく計算されてしまいます。
--------game situation--------
[NumberOfRemainingActions]=2
[Gold]=40
[Health]=6
--------base model--------
[Action]=B [NextGold]=60 [NextHealth]=3 [DeathRisk]=0.5
[Action]=C [NextGold]=40 [NextHealth]=5 [DeathRisk]=0.4
[Action]=A [NextGold]=20+40=60 [NextHealth]=6-3=3 [DeathRisk]=1.0
</Calculation>
<Reason>
Action A has a high risk of death due to its significant health cost,
which could result in a guaranteed death given only two remaining actions.
Action B offers a moderate increase in gold and a lower health penalty,
while Action C provides a balanced approach. However,
since Action C still leaves some room for survival,
it is the safest choice among the available options.
</Reason>
<Action>C</Action><|endoftext|>
--------trained model--------
[Action]=B [NextGold]=60 [NextHealth]=3 [DeathRisk]=0.33
[Action]=C [NextGold]=40 [NextHealth]=5 [DeathRisk]=0.20
[Action]=A [NextGold]=50 [NextHealth]=3 [DeathRisk]=0.33
</Calculation>
<Reason>
Action B gives the highest gold but also has a higher risk of death compared to Action C.
Action A provides a moderate amount of gold and health loss,
but the randomness of x makes it less predictable.
Given the remaining actions, B and A have the same DeathRisk,
so I choose B due to its higher gold reward.
</Reason>
<Action>B</Action><|endoftext|>
事例その2
こちらについてもうまく行っています。学習後の出力で、行動A,Bの危険性を正しく評価できたことによって、妥当な説明を行いながら安全な行動Cを選択できています。
ただ、計算の部分において、NextHealth=0になる場合に、NextGold=0になるように報酬を与えていたのですが、あまりうまくいきませんでした。こういった特例的な処理は、LLMは苦手に見えます。また、私が作成した指示文を見ても、この点についてうまく説明できていないように思いました。
--------game situation--------
[NumberOfRemainingActions]=2
[Gold]=45
[Health]=3
--------base model--------
[Action]=B [NextGold]=67.5 [NextHealth]=0 [DeathRisk]=1.0
</Calculation>
<Reason>
Choosing B will result in a significant increase in Gold,
but Health will drop to 0, leading to certain death.
The DeathRisk is capped at 1.0, meaning the player is guaranteed to die in the next turn.
</Reason>
<Action>B</Action> <Action>C</Action> <Action>A</Action> <Action>C</Action> <Action>A</Action> <Action>B</Action> <Action>C</Action> <Action>A</Action> <Action>B</Action> <Action>C</Action> <Action>A</Action> <Action>B</Action> <Action>C</Action> <Action>A</Action> <Action>B</Action> <Action>C</Action> <Action>A</Action> <Action>B</Action> <Action>C</Action> <Action>A</Action> <Action>B</Action> <Action>C</Action> <Action>A</Action> <Action>B</Action> <Action>C</Action> <Action>A</Action> <Action>B</Action> <Action>C</Action> <Action>A</Action> <Action>B</Action> <Action>C</Action> <Action>A</Action> <Action>B</Action> <Action>C</Action> <Action>A</Action> <Action>B</Action> <Action>C</Action> <Action>A</Action> <Action>B</Action> <Action>C</Action> <Action>A</Action> <Action>B</Action> <Action>C</Action> <Action>A</Action> <Action>B</Action> <Action>C</Action> <Action>A</Action> <Action>B</Action> <Action>C</Action> <Action>A</Action> <Action>B</Action> <Action>C</Action> <Action>A</Action> <
--------trained model--------
[Action]=B [NextGold]=67 [NextHealth]=0 [DeathRisk]=1.0
[Action]=A [NextGold]=48 [NextHealth]=0 [DeathRisk]=1.0
[Action]=C [NextGold]=45 [NextHealth]=2 [DeathRisk]=0.5
</Calculation>
<Reason>
Action B and Action A both result in immediate death due to negative health,
so their gold values are not useful.
Action C results in a moderate loss of health while maintaining current gold,
which is the safest option given the remaining actions.
</Reason>
<Action>C</Action><|endoftext|>
-----------------------------
結果の考察
最後にこの手法による言語モデルの強化学習について、何点か気づいた点がありますので、書いておこうと思います。
- 強化学習によって、モデルの思考の複雑さは向上しない。
今回の実験で、行動Aの期待値の計算は、強化学習後も精度が悪かったです。これは、Healthの値が0になった場合はgoldを0にすることや、1~5の間でランダムに体力が消費されることなど、考慮すべき状況がモデルの許容する複雑さを超えているのかと思います。どの程度まで複雑な思考ができるかは、ほぼモデルサイズに依存する感じではないでしょうか。
そして、強化学習において鍛えられる能力は、すでに持っている思考能力を適切に使う能力だと言えると考えます。人間と同じで、例えば積分の計算方法を十分に理解している学生は、演習問題を繰り返すことで成績を伸ばすことができますが、そもそも積分の計算方法を理解していない場合、いくら演習問題を解いても正解率は向上しない、みたいな感じかと。 - 途中の理由説明(<Reason>タグ内)の妥当性を全く保証しない。
今回の手法は、「計算の正しさ」と「選択した行動の期待値」を出力として与えているため、途中の説明がいかに妥当なものになっているかは、全く保証しません。どのような理由づけをしても、選択した行動が正しければモデルに帰ってくる報酬は全く同じになるのです。
そしてこれが、途中計算を指示した理由にもなっています。途中計算を入れることによって、ある程度妥当な説明を引き出すことができましたが、これがない場合は、かなり単調な説明になっていました。
以上です。まとまりがない文章になってしまいましたが、ここまで読んでいただきありがとうございました。
Discussion