メインコンテンツまでスキップ

PPOとは?

PPOの概要

概要 (画像は、Geminiで作成されたものです)

PPO(Proximal Policy Optimization)は、強化学習における方策勾配法(Policy Gradient Methods)の新しいファミリです。環境との相互作用によるデータのサンプリングと、確率的勾配上昇法を用いた「サロゲート(代替)」目的関数の最適化を交互に行うアルゴリズムです。

標準的な方策勾配法がデータサンプルごとに1回の勾配更新を行うのに対し、PPOはミニバッチ更新による複数エポックの学習を可能にする斬新な目的関数(Clipped probability ratios)を提案しています。これにより、TRPO(Trust Region Policy Optimization)が持つデータ効率や信頼性といった利点を維持しつつ、実装がはるかに簡単で汎用性が高く、経験的に優れたサンプル効率を実現しています。

OpenAIによって発表されて以来、シミュレートされたロボットの移動やAtariのゲームプレイなど、様々なベンチマークタスクにおいて他のオンライン方策勾配法を上回る性能を示し、現在では強化学習のデファクトスタンダードとして広く活用されています。

これまでのアルゴリズムの課題とPPOの位置づけ

従来手法との違い
(画像は、Geminiで作成されたものです)

ニューラルネットワークを関数近似器として用いる強化学習において、PPOに至るまでに以下のアルゴリズムが発展してきました。しかし、それぞれに課題がありました。

  1. 価値ベースの手法(DQN など): 経験再生(Experience Replay)や固定ターゲットネットワークにより、離散的な行動空間ではうまく機能しますが、連続制御問題への適用が難しく、方策を直接最適化できないという限界がありました。
  2. 方策勾配法(REINFORCEA2C など): 方策を直接学習でき、連続値制御も可能ですが、更新のステップサイズ(学習率)に非常に敏感です。一度でも方策を大きく更新しすぎると性能が致命的に崩壊してしまうという不安定性がありました。
  3. 信頼領域法(TRPO): 方策勾配法の弱点を克服するため、「KLダイバージェンス」を用いた制約付き最適化を導入し、単調な改善(性能崩壊の防止)を数学的に保証しました。しかし、逆行列やヘッセ行列の計算(共役勾配法)が必要であり、実装が複雑で計算コストが高いことや、Dropoutなどのノイズを伴うネットワーク構造と相性が悪いという欠点がありました。

PPOは、これらの課題を解決し、TRPOの持つ「更新の安全性(単調改善)」を維持しながら、DQNやA2Cのように「1次最適化(通常の勾配降下法)のみで実装できるシンプルさ」 を実現したアルゴリズムです。

PPOの処理概要

処理概要
(画像は、Geminiで作成されたものです)

PPO(Actor-Criticスタイル)のアルゴリズムは、環境と相互作用してデータを収集するフェーズと、収集したデータを使ってネットワークを最適化するフェーズを繰り返します。固定長の軌跡(Trajectory)セグメントを使用する学習の流れは以下のようになります。

  1. データのサンプリング (Data Collection): まず、現在のAIの方策(ポリシー、πθold\pi_{\theta_{old}})を使って、これから学習するためのデータを集めます。このプロセスは、以下のステップで進められます。

    • 基本的な流れ(1人プレイ): まず、1人の「アクター」(行動者)が環境の中で行動します。アクターは、現在の状態 sts_t を見て、方策 π(atst)\pi(a_t \vert s_t) に従って次に行うべき行動 ata_t を決定します。行動した結果、環境から新しい状態 st+1s_{t+1}、報酬 rtr_t、そして「ゲーム終了」かどうかを示す情報を受け取ります。この1ステップの遷移 (st,at,rt,st+1)(s_t, a_t, r_t, s_{t+1})TT 回(例: 20ステップ)繰り返して、一続きのプレイデータ(「軌跡」や「エピソード」τ\tau)として保存します。

      τ=(s0,a0,r0,s1,a1,r1,,sT1,aT1,rT1,sT)\tau = (s_0, a_0, r_0, s_1, a_1, r_1, \dots, s_{T-1}, a_{T-1}, r_{T-1}, s_T)

    • 効率化のための並列化(N人での同時プレイ): 1人分のプレイデータだけでは、内容に偏りが生じたり、学習に必要なデータ量を集めるのに時間がかかったりします。そこでPPOでは、N 人のアクター(例: 8人)を用意し、それぞれが同時に(並列で) 異なる環境や初期状態でプレイを開始します。各アクターがそれぞれ T ステップ分のデータを集めることで、多様で大量のデータを一度に効率よく収集できます。最終的に、このフェーズでは合計 N × T ステップ分のデータが集まります。

  2. アドバンテージの計算 (Advantage Estimation): 次に、集めた N × T 個のデータ一つひとつに対して、「その行動が平均と比べてどれだけ良かったか」を評価する指標であるアドバンテージ(Advantage) A^t\hat{A}_t を計算します。

    (※この計算は全データを混ぜて行うのではなく、NN 人のアクターがそれぞれ集めた「長さ TT の軌跡」ごとに独立して行われます。)

    アドバンテージは、基本的には「実際に得られた将来までの報酬の合計(収益 GtG_t)」から「あらかじめ予測していたその状態の平均的な価値(ベースライン V(st)V(s_t))」を引くことで計算されます。

    A^t=GtV(st)\hat{A}_t = G_t - V(s_t)

    つまり、単純な即時報酬を見るのではなく、長期的な視点で「その状況でその行動を選択したことが、事前の予測よりも本当に良い結果をもたらしたか」を測ります。

    なぜ「未来」を足し合わせるのか?(GAEの導入)

    アドバンテージを計算する際、以下の2つの極端な方法にはそれぞれ弱点があります。

    1. 最後までやり切った結果(収益 GtG_tモンテカルロ法)を使う: 正確(バイアス小)ですが、途中のまぐれや不運が混ざりすぎてデータがブレブレ(分散大)になります。
    2. 1歩先だけの予測(TD誤差 δt\delta_t)を使う: δt=rt+γV(st+1)V(st)\delta_t = r_t + \gamma V(s_{t+1}) - V(s_t) データのブレは少ない(分散小)ですが、そもそもの「予測」である V(st+1)V(s_{t+1}) が間違っていたら元も子もありません(バイアス大)。

    そこで GAE (Generalized Advantage Estimation: 一般化アドバンテージ推定) は、「1歩先の予測(δt\delta_t)」「2歩先の予測(δt+1\delta_{t+1})」「3歩先の予測…」と、未来に向かって発生するTD誤差を全部足し合わせることで、両者のいいとこ取り(バランス)をしようと考えました。

    バイアスと分散のトレードオフについて

    ここで説明されている「モンテカルロ法」と「TD学習」の両極端な性質(バイアスと分散のジレンマ)は、強化学習における永遠のテーマです。GAEがどのようにこのジレンマを解決しようとしているのか、その前提知識をより深く理解したい方は、ぜひモンテカルロ法(Monte Carlo Method)とは? および TD学習(時間的差分学習)とは? の記事もあわせてご覧ください。

    λ\lambda の役割:未来の「不確実性」を割り引く

    ここで登場するのが λ\lambda です。未来のTD誤差を足していく際、1歩先の誤差(δt\delta_t)は今の状況に近いので比較的信用できますが、10歩先の誤差(δt+9\delta_{t+9})となると、その間にどんなランダムな出来事が起こるか分からないため、非常に不確実になります。

    そこで、足し合わせる式にパラメータ λ\lambda0λ10 \le \lambda \le 1 の値)を掛け合わせます。

    A^t=δt+(γλ)δt+1++(γλ)Tt1δT1\hat{A}_t = \delta_t + (\gamma\lambda)\delta_{t+1} + \dots + (\gamma\lambda)^{T-t-1}\delta_{T-1}

    例えば λ=0.9\lambda = 0.9 とすると、

    • 現在のTD誤差:1.01.0 倍の重み
    • 1歩先のTD誤差:0.90.9 倍に重みを下げる
    • 2歩先のTD誤差:0.9×0.9=0.810.9 \times 0.9 = 0.81 倍に重みを下げる
    • 3歩先のTD誤差:0.9×0.9×0.9=0.7290.9 \times 0.9 \times 0.9 = 0.729 倍に重みを下げる...

    このように、λ\lambda を何度も掛け合わせることで、「遠い未来の不確実な情報ほど、影響力(重み)を徐々に小さくしていく」という操作を行っているのです。

    極端な例で考えてみると…

    λ\lambda を極端な値に設定すると、このダイヤルの意味がよりハッキリします。

    • λ=0\lambda = 0 に設定した場合: 未来の項がすべて 00 になるため、A^t=δt\hat{A}_t = \delta_t となります。つまり「1歩先の情報しか見ない(バイアス大・分散小)」という状態に戻ります。
    • λ=1\lambda = 1 に設定した場合: 未来の項が全く減衰しません。これは数学的に計算していくと、最終的に「最後までやり切った結果(収益 GtG_t)をそのまま使う(バイアス小・分散大)」のと同じことになります。

    つまり λ\lambda は、この「両極端な手法の間をシームレスに繋ぎ、一番ちょうどいいバランス(バイアスと分散のトレードオフの最適化)を見つけるために、後から数学的に導入された便利なパラメータ」だったというわけです。

    GAEの計算イメージ(3ステップの例)

    計算のイメージを掴むために、あえて非常に短い3ステップ(t=0,1,2t=0, 1, 2)の軌跡を例に、現在(t=0t=0)のアドバンテージ A^0\hat{A}_0 を計算してみましょう。

    1. 前提となる数値を設定する 計算をシンプルにするため、割引率 γ\gamma は 1.0(減衰なし)とし、今回は未来の不確実性を調整する λ\lambda の効果だけに注目します。

    • γ\gamma(割引率): 1.0
    • λ\lambda(GAEの減衰パラメータ): 0.5
    • 各ステップで発生した「1歩先の予測とのズレ(TD誤差)」:
      • 現在(t=0t=0)のTD誤差 δ0\delta_0 = 10 (予想よりかなり良かった)
      • 1歩先(t=1t=1)のTD誤差 δ1\delta_1 = 8 (予想より良かった)
      • 2歩先(t=2t=2)のTD誤差 δ2\delta_2 = -4 (予想より悪かった)

    2. GAEの公式に当てはめる 今(t=0t=0)のアドバンテージ A^0\hat{A}_0 です。γ=1.0\gamma=1.0 なので、式は以下のようになります。 A^0=δ0+λδ1+λ2δ2\hat{A}_0 = \delta_0 + \lambda\delta_1 + \lambda^2\delta_2

    ここに数値を代入して計算します。

    • 現在のTD誤差: 1010 (そのまま 100% 信用する)
    • 1歩先のTD誤差: 0.5×8=40.5 \times 8 = 4 (半分だけ信用する)
    • 2歩先のTD誤差: 0.52×4=0.25×4=10.5^2 \times -4 = 0.25 \times -4 = -1 (かなり不確実なので 25% だけ信用する)

    すべて足し合わせます。 A^0=10+41=13\hat{A}_0 = 10 + 4 - 1 = 13 現在のアドバンテージは 13 と計算されました。

    3. λ\lambda を変えるとどうなるか?(比較) ここからが λ\lambda の面白いところです。もし λ\lambda を両極端な値(0 または 1)に設定していたら、計算結果はどう変わっていたかを比較してみましょう。

    λ\lambda の設定値計算式算出されるアドバンテージ A^0\hat{A}_0特徴
    λ=0\lambda = 010+0+010 + 0 + 010目の前の結果のみを信じる(バイアス大・分散小)。未来の情報は完全に無視されます。
    λ=0.5\lambda = 0.510+4110 + 4 - 113バランス型。未来の情報を少しずつ割り引きながら取り入れます。
    λ=1\lambda = 110+8410 + 8 - 414未来の結果も100%信じる(バイアス小・分散大)。最後までやり切った実際の収益をそのまま評価に使うことになります。

    このように、λ\lambda を 0.5 に設定したことで、「目の前の確実な大成功(10)」を一番重視しつつも、「未来もそこそこ良かった(8)」「でも最後はちょっと失敗した(-4)」という未来の情報を、不確実性に合わせて適度にトッピングすることができました。

    この式を NN 人分の軌跡それぞれに適用することで、最終的に N×TN \times T 個すべてのデータに対して高精度なアドバンテージ A^t\hat{A}_t が割り当てられます。

  3. 複数エポックの最適化 (Optimization): 最後に、方策を更新する学習(最適化)のフェーズです。PPOの核心部分であり、以下の特徴があります。

    • サロゲート損失の利用: PPOでは、方策を更新しすぎないように「ブレーキ」をかける仕組みを持つ特別な目的関数(サロゲート損失)を計算します。これにより、学習が一歩進むごとに方策が急激に変化して性能が不安定になるのを防ぎます。(※サロゲート損失の具体的な仕組みについては、後述の「TRPOの複雑な制約をシンプルに実現するクリッピング (Clipped Surrogate Objective)」で詳しく解説します)
    • データの再利用(複数エポック学習): 一度集めた N × T 個の貴重なデータを一度の学習で使い捨てるのは非効率です。PPOでは、同じデータセットを使って K エポックK回)繰り返し学習します。
    • ミニバッチ学習: さらに、N × T 個のデータをいくつかの小さな塊(ミニバッチ)に分割し、ミニバッチごとに勾配を計算してパラメータを更新します。これにより、学習プロセスがさらに安定し、効率化されます。

この3つのステップを何度も繰り返すことで、PPOは方策を徐々に改善していきます。

TRPOの複雑な制約をシンプルに実現するクリッピング (Clipped Surrogate Objective)

PPOの最も重要なイノベーションである 「クリップされたサロゲート目的関数 (Clipped Surrogate Objective)」 を理解するために、まずはこれまでのアルゴリズムの目的関数を少し振り返ってみましょう。

REINFORCEA2C のような標準的な方策勾配法では、以下のような目的関数を最大化することで方策を更新していました。

LPG(θ)=E^t[logπθ(atst)A^t]L^{PG}(\theta) = \hat{\mathbb{E}}_t \left[ \log \pi_\theta(a_t \vert s_t) \hat{A}_t \right]

各アルゴリズムの目的関数との対応

ここで A^t\hat{A}_t は、その行動の良さを示す指標です。REINFORCEの記事で 解説した方策勾配定理の数式 θJ(θ)=E[θlogπθ(atst)Gt]\nabla_\theta J(\theta) = \mathbb{E}[\sum \nabla_\theta \log \pi_\theta(a_t|s_t) G_t] では GtG_t(割引報酬和)が、A2Cの記事では アドバンテージ AtA_t が、それぞれこの A^t\hat{A}_t の役割を担っています。

なお、A2Cの記事では Actor Loss を LActor(θ)=E[logπθ(atst)At]L^{Actor}(\theta) = - \mathbb{E}[\log \pi_\theta(a_t|s_t) A_t] と、符号を反転した形で記載しています。これは オプティマイザが「最小化」を行うためであり、LActorL^{Actor} を最小化することは、 ここでの LPGL^{PG} を最大化することと等価です。

これはシンプルに「アドバンテージ A^t\hat{A}_t が高い(良かった)行動の確率を高める」という直感的な式ですが、この関数をそのまま最適化し続けると、1回の更新で方策が急激に変化しすぎ、学習が崩壊してしまうという問題がありました。(※この現象については、A2Cの記事における「方策の崩壊(Policy Collapse)のシミュレーション」で実演しています)

これを防ぐため、TRPO では更新前(古い方策)と更新後(新しい方策)の確率比に注目しました。 新しいポリシー πθ\pi_\theta と古いポリシー πθold\pi_{\theta_{old}} の行動確率の比を rt(θ)r_t(\theta) と定義します。

rt(θ)=πθ(atst)πθold(atst)r_t(\theta) = \frac{\pi_\theta(a_t \vert s_t)}{\pi_{\theta_{old}}(a_t \vert s_t)}

(※更新前は方策が同じなので rt(θold)=1r_t(\theta_{old}) = 1 となります)

TRPOは、この確率比を用いた目的関数 LCPI(θ)=E^t[rt(θ)A^t]L^{CPI}(\theta) = \hat{\mathbb{E}}_t \left[ r_t(\theta) \hat{A}_t \right] を、KLダイバージェンスを用いた厳密な制約の下で最大化することで、方策の崩壊を防ぎました。しかし、その計算プロセスは非常に複雑なものでした。

TRPOのサロゲート目的関数との関係

この LCPI(θ)L^{CPI}(\theta) は、TRPOの記事における「ペナルティからハード制約へ」 で解説した以下の「サロゲート目的関数」と全く同じ数式です。

Lθold(θ)=Esρθold,aπθold[πθ(as)πθold(as)Aθold(s,a)]L_{\theta_{old}}(\theta) = \mathbb{E}_{s \sim \rho_{\theta_{old}}, a \sim \pi_{\theta_{old}}} \left[ \frac{\pi_\theta(a|s)}{\pi_{\theta_{old}}(a|s)} A_{\theta_{old}}(s,a) \right]

PPO論文では、この方策勾配法と重要度サンプリングを組み合わせた式を "Conservative Policy Iteration (CPI)" に由来して LCPIL^{CPI} と呼んでいます。

PPOは、TRPOの「確率比 rt(θ)r_t(\theta) を用いる」という良いアイデアを引き継ぎつつ、複雑な制約計算の代わりに、「確率比 rt(θ)r_t(\theta) の値そのものを一定範囲内にクリッピング(制限)してしまえば、安全に更新できるのではないか?」 という極めてシンプルなアプローチを採用しました。

このアイデアを数式で表した、PPOのメインの目的関数は以下の式になります。

LCLIP(θ)=E^t[min(rt(θ)A^t,clip(rt(θ),1ϵ,1+ϵ)A^t)]L^{CLIP}(\theta) = \hat{\mathbb{E}}_t \left[ \min(r_t(\theta)\hat{A}_t, \text{clip}(r_t(\theta), 1-\epsilon, 1+\epsilon)\hat{A}_t) \right]

ここで、ϵ\epsilon はハイパーパラメータ(例:ϵ=0.2\epsilon = 0.2)です。この目的関数の設計には以下のようなモチベーションがあります。

  1. min\min の中の最初の項 rt(θ)A^tr_t(\theta)\hat{A}_t は、TRPOでもベースとなっているConservative Policy Iteration (CPI) の目的関数です。制約なしにこれを最大化すると、ポリシーの過度な更新を招いてしまいます。
  2. min\min の中の2つ目の項 clip(rt(θ),1ϵ,1+ϵ)A^t\text{clip}(r_t(\theta), 1-\epsilon, 1+\epsilon)\hat{A}_t は、確率比をクリップすることで、rtr_t が区間 [1ϵ,1+ϵ][1-\epsilon, 1+\epsilon] の外へ移動するインセンティブを取り除きます。
  3. これら2つの項の最小値(min\min)をとることで、最終的な目的関数はクリップされていない目的関数の下限(つまり悲観的なバウンド)となります。

このスキームの巧妙な点は、「目的関数を改善させるような確率比の変更(大きすぎる更新)のみを無視し、目的関数を悪化させるような変更はそのまま含める」 という点にあります。これにより、大きすぎるポリシーの更新(学習の崩壊)を防ぎつつ、安全な範囲内で最大限の改善を行うことができます。

具体例:クリッピングはどのように働くのか?

この数式が実際にどのように「行き過ぎた更新だけを無視し、悪化にはペナルティを与える」のか、ϵ=0.2\epsilon = 0.2 (クリップ範囲 0.81.20.8 \sim 1.2)とした時の具体的な数値例で確認してみましょう。

【ケース1】 アドバンテージがプラス(A^t=+10\hat{A}_t = +10):良かった行動なので確率を上げたい場合

新しい確率比 rt(θ)r_t(\theta)本来のスコア rtA^tr_t\hat{A}_tクリップされたスコア最小値 min\min の結果勾配(学習の挙動)
1.11.1 (適度に上げた)11111.1×10=111.1 \times 10 = 111111学習する(そのままスコアが伸びる)
1.51.5 (上げすぎた)15151.2×10=121.2 \times 10 = 121212学習ストップ(12で頭打ちになり、これ以上確率を上げても勾配が0になる)
0.50.5 (なぜか下げた)550.8×10=80.8 \times 10 = 855ペナルティ(悪化する方向への更新はそのまま反映される)

【ケース2】 アドバンテージがマイナス(A^t=10\hat{A}_t = -10):悪かった行動なので確率を下げたい場合

新しい確率比 rt(θ)r_t(\theta)本来のスコア rtA^tr_t\hat{A}_tクリップされたスコア最小値 min\min の結果勾配(学習の挙動)
0.90.9 (適度に下げた)9-90.9×10=90.9 \times -10 = -99-9学習する(そのままスコアが伸びる)
0.50.5 (下げすぎた)5-50.8×10=80.8 \times -10 = -88-8学習ストップ(-8で頭打ちになり、これ以上確率を下げても勾配が0になる)
1.51.5 (なぜか上げた)15-151.2×10=121.2 \times -10 = -1215-15ペナルティ(悪化する方向への更新はそのまま反映される)

このように、単なるハードクリッピング(上限・下限を強制的にカットするだけ)ではなく min\min 関数を組み合わせることで、「安全な範囲(0.81.20.8 \sim 1.2)での更新は許可し、それを超えてさらに良くしようとする欲張った更新は無視する。ただし、状況を悪化させるような更新に対しては一切容赦せずペナルティ(マイナスの勾配)を与える」という非常に賢い非対称な挙動を実現しているのです。

Adaptive KL Penalty Coefficientについて

PPOの論文では、クリッピングの代替として、TRPOのようにKLダイバージェンスへのペナルティを目的関数(方策勾配)から直接差し引く手法(Adaptive KL Penalty)も提案されています。数式にすると以下のようになります(論文の式(8))。

LKLPEN(θ)=E^t[πθ(atst)πθold(atst)A^tβKL[πθold(st),πθ(st)]]L^{KLPEN}(\theta) = \hat{\mathbb{E}}_t \left[ \frac{\pi_\theta(a_t|s_t)}{\pi_{\theta_{old}}(a_t|s_t)} \hat{A}_t - \beta \text{KL}[\pi_{\theta_{old}}(\cdot|s_t), \pi_\theta(\cdot|s_t)] \right]

ここで問題となるのが、ペナルティの強さを決める係数 β\beta をいくつに設定すべきかです。この手法(Adaptive KL Penalty)では、β\beta を固定せず、更新による実際のKLダイバージェンスの大きさ(dd)が、人間があらかじめ設定した「方策の変化をこれくらいに抑えたい」という目標値となるハイパーパラメータ(dtargd_{targ} に収まるように、β\beta を適応的(Adaptive)に調整します。 具体的には、数エポックの学習の直後に実際のKLダイバージェンスの平均値 dd を計算し、dddtargd_{targ} を比較して以下のルールで次回の β\beta を更新します。

  • d<dtarg/1.5d < d_{targ} / 1.5 の場合(方策が全然変化していない): ペナルティが強すぎるので β\beta を半分にする。
  • d>dtarg×1.5d > d_{targ} \times 1.5 の場合(方策が変化しすぎている): ペナルティが弱すぎるので β\beta を2倍にする。

このように動的に β\beta を調整して dtargd_{targ} を達成しようと試みますが、実験の結果、このKLペナルティを用いた手法はクリップされたサロゲート目的関数(CLIP)よりもパフォーマンスが劣ることが分かっています。そのため、現在広く使われているPPOの実装のほとんどはCLIPを採用しています。

【重要】RLHF(InstructGPT など)におけるKLペナルティとの違い

ここで提案されている「Adaptive KL Penalty」は、PPO単体で方策の更新幅を制限する(TRPOの信頼領域制約をペナルティ項で代替する)ためのものであり、CLIPに劣るため現在ではほぼ使われません。

しかし、大規模言語モデル(LLM)を人間からのフィードバック(RLHF)で学習させる文脈(InstructGPT など)においては、これとは異なる目的で「KLペナルティ」が極めて重要な役割を果たします。 RLHFにおけるKLペナルティは、学習中のモデル(方策)が初期の教師あり微調整(SFT)モデルから離れすぎて、言語能力が破綻したり(言語の崩壊)、報酬モデルの弱点を突く「報酬ハッキング(Reward Hacking)」を起こしたりするのを防ぐために、報酬関数(目的関数)に直接差し引くペナルティとして追加されます。

PPO単体での「方策更新制限」としてのKLペナルティ(本論文)と、RLHFにおける「初期モデルからの逸脱抑制」としてのKLペナルティは、目的も数式上の組み込み位置も異なるため、混同しないよう注意が必要です。詳しくは後述の補足セクションで解説します。

PPOの構成技術要素(詳細)

PPOをActor-Criticアーキテクチャで実装し、ポリシーと価値関数でニューラルネットワークのパラメータを共有する場合、単一の損失関数を構成して最適化を行います。この目的関数は以下の3つの要素を結合したものです。

LtCLIP+VF+S(θ)=E^t[LtCLIP(θ)c1LtVF(θ)+c2S[πθ](st)]L_t^{CLIP+VF+S}(\theta) = \hat{\mathbb{E}}_t \left[ L_t^{CLIP}(\theta) - c_1 L_t^{VF}(\theta) + c_2 S[\pi_\theta](s_t) \right]

  1. ポリシーサロゲート損失 (LtCLIPL_t^{CLIP}): 前述のクリップされた目的関数です。行動の良し悪し(アドバンテージ)に基づいてポリシーを更新します。
  2. 価値関数の誤差 (LtVFL_t^{VF}): 状態価値関数の予測精度を高めるための二乗誤差損失です。(Vθ(st)Vttarg)2(V_\theta(s_t) - V_t^{targ})^2 として計算されます。係数 c1c_1 によって重み付けされます。(※この式は、A2Cの記事における LCritic(θ)=E[(RtVθ(st))2]L^{Critic}(\theta) = \mathbb{E}[(R_t - V_\theta(s_t))^2]全く同じものです。論文上の表記として「実際に得られた収益(RtR_t)」を「予測ターゲット(VttargV_t^{targ})」と呼んでいるだけで、中身は同じ平均二乗誤差です)
  3. エントロピーボーナス (SS): 十分な探索を確保するために追加される項です。ポリシーの出力する確率分布のエントロピー S[πθ](st)S[\pi_\theta](s_t) を加算し、係数 c2c_2 で重み付けします。これにより、ポリシーが早すぎる段階で決定論的になる(局所解に陥る)のを防ぎます。(※これも、A2Cの記事で解説した「エントロピー正則化」と全く同じ仕組みです。PPO論文ではエントロピーの記号として HH の代わりに SS を用い、重み係数を β\beta の代わりに c2c_2 と表記しているだけです)

大規模言語モデル(LLM)アライメントへの応用:InstructGPTへの橋渡し

PPOは元々、ロボット制御やAtariのゲームプレイなど、一般的な強化学習タスクのために開発されましたが、現在では大規模言語モデル(LLM)を人間にアライメントさせる「RLHF(人間からのフィードバックによる強化学習)」の基盤技術として広く知られています(例:ChatGPTのベースとなったInstructGPT)。

PPOがLLMの学習で選ばれる理由

LLMのテキスト生成における行動空間(語彙サイズ)は数万に及びます。この巨大な空間で方策勾配法を行うと、わずかな更新でモデルが全く意味不明な言語を生成する「言語崩壊」に陥る危険性が極めて高くなります。PPOの 「クリップされたサロゲート目的関数」 は、この巨大な行動空間においても方策の急激な変化に強力なブレーキをかけるため、極めて安全で安定した学習(ファインチューニング)を可能にします。

LLMへの適用時の対応関係

LLMにPPOを適用する場合、強化学習の各要素は以下のように対応します。

強化学習の用語言語モデル(LLM)における対応
状態 sts_tプロンプトと、そこまでに生成したトークン列 (x,y<t)(x, y_{\lt t})
行動 ata_t次に選択する1つのトークン yty_t
方策 πθ\pi_{\theta}言語モデルそのもの(次トークンの出力確率分布)
報酬 rtr_t報酬モデルのスコア + ペナルティ項(後述)
エピソード1つの応答文が完成する(EOSトークンの生成や最大長到達)まで

InstructGPTに向けた重要な注意点(KLペナルティの意味合い)

PPO単体の理論では、本記事で解説したように、方策の更新幅を制限するために「Adaptive KL Penalty」という手法も提案されていましたが、CLIP(クリッピング)の方が優秀であると結論づけられました。

しかし、InstructGPTなどのRLHFにおいては、「KLペナルティ」が全く別の重要な目的で復活します。 RLHFでは、不完全な報酬モデルを最適化しようとするため、言語モデルが報酬モデルの裏をかいて異常な文章で高得点を稼ごうとする 「報酬ハッキング」 が発生します。これを防ぐため、学習前の初期モデル(SFTモデル)から出力分布が離れすぎないように、報酬そのものからKLダイバージェンスを差し引くという処理を行います。

rtotal(x,y)=rθ(x,y)βlogπϕRL(yx)πSFT(yx)r_{\text{total}}(x, y) = r_{\theta}(x, y) - \beta \log \frac{\pi_{\phi}^{RL}(y \mid x)}{\pi^{SFT}(y \mid x)}

PPOはこの「ペナルティ付きの報酬」を最大化するように、通常のクリッピング目的関数を用いて安全にモデルを更新します。このように、PPOの強固な最適化能力は、最新のLLM開発において不可欠なエンジンとなっています。

このRLHFの具体的な枠組みと、PPOがどのように人間の意図を反映させるのかについては、次回の記事 InstructGPTとは? で詳しく解説します。

実装における重要ノウハウ:直交重み初期化(Orthogonal Initialization)

後述するPPOのシンプルな実装例にも組み込まれていますが、PPOを実際に安定して動作させるための重要なノウハウとして「直交重み初期化(Orthogonal Initialization)」があります。

PPOの原論文自体には詳細な記載はありませんが、後年に公開されたOpenAI Baselinesの実装などを通じて、PPOの性能を引き出すためのデファクトスタンダードとして広く認知されるようになりました。

直交初期化の理論的背景

直交行列とは、転置行列が逆行列になる(WTW=IW^T W = I)ような行列のことです。ニューラルネットワークの重み行列 WW を直交行列で初期化することには、以下のような数学的な利点があります。

  1. ノルムの保存(勾配消失・爆発の防止): 直交行列をベクトルに掛けても、そのベクトルの長さ(ノルム)は変化しません。つまり、深い層を経由しても信号が減衰したり発散したりするのを防ぐことができます。これにより、学習の初期段階から安定した勾配がネットワーク全体に伝わります。
  2. 特徴量の独立性: 直交する重みベクトルは、入力に対して互いに無相関な(独立した)特徴を抽出するように機能します。これにより、ネットワークの表現力を効率よく活用し、学習の効率を高めることができます。

PPOにおける具体的な活用

PPOの実装では、単に直交行列で初期化するだけでなく、層の役割(ActorかCriticか、隠れ層か出力層か)に応じて出力のスケール(標準偏差 std)を適切に調整する手法が一般的に用いられます。

  • 隠れ層: 通常のスケール(例:活性化関数が tanh\text{tanh} の場合は 2\sqrt{2} など)で直交初期化を行い、安定した特徴抽出を促します。
  • Actorの出力層(方策): スケールを非常に小さく(例:std=0.01)して初期化します。これにより、学習開始時の各行動の選択確率がほぼ均等(一様分布に近い状態)になり、方策のエントロピーが最大化されます。初期段階で特定の行動に偏ることなく、幅広い探索を行うために極めて重要です。
  • Criticの出力層(状態価値): スケールを std=1.0 などとし、予測価値の初期スケールを維持します。

これらの工夫は、PPOのアルゴリズム自体の数式(目的関数のクリッピング等)と同じくらい、最終的なパフォーマンスと学習の安定性に大きな影響を与えることが経験的に分かっています。実際、Engstrom et al. (2020) の "Implementation Matters in Deep Policy Gradients" などの研究により、直交初期化をはじめとするコードレベルの実装詳細がPPOの性能に極めて大きな影響を与えていることが実証されています。

PPOの実装(概念的なシンプルな実装)

ここでは、論文に記載されている Algorithm 1 (Actor-Critic Style) に基づく、PyTorchを用いたPPOのシンプルな概念実装を示します。

ライブラリのインストール

Gymnasiumなどの強化学習環境とPyTorchをインストールします。

!pip install torch numpy gymnasium
!pip install stable-baselines3[extra] sb3-contrib matplotlib # 比較のTRPOの利用に必要

モデルの定義 (Actor-Critic Network)

以下のコードでは、PyTorchを用いてPPOのActor-Criticネットワークを実装します。ここでは方策(確率分布)を出力するActorと状態価値を出力するCriticを、共通の構造を持つ独立したネットワークとして定義しています。 ネットワークの層構成(64ユニットの2層隠れ層とtanh活性化関数)はPPO原論文の実験設定を模していますが、それに加えて 後年のOpenAI Baselines実装等で広く知られるようになった実装ノウハウである直交重み初期化(Orthogonal Initialization) も組み込んで、訓練初期の安定性を高めています。

import torch
import torch.nn as nn
from torch.distributions import Categorical
import gymnasium as gym
import numpy as np

# --- 1. デバイス設定 ---
device = torch.device('cpu')

# --- 2. 直交重み初期化関数 ---
def layer_init(layer, std=np.sqrt(2), bias_const=0.0):
torch.nn.init.orthogonal_(layer.weight, std)
torch.nn.init.constant_(layer.bias, bias_const)
return layer

# --- 3. クラス定義 ---
class ActorCritic(nn.Module):
def __init__(self, state_dim, action_dim):
super(ActorCritic, self).__init__()
# 直交初期化を適用。出力層は初期エントロピーを高く保つため std=0.01 に設定
self.actor = nn.Sequential(
layer_init(nn.Linear(state_dim, 64)),
nn.Tanh(),
layer_init(nn.Linear(64, 64)),
nn.Tanh(),
layer_init(nn.Linear(64, action_dim), std=0.01),
nn.Softmax(dim=-1)
)
self.critic = nn.Sequential(
layer_init(nn.Linear(state_dim, 64)),
nn.Tanh(),
layer_init(nn.Linear(64, 64)),
nn.Tanh(),
layer_init(nn.Linear(64, 1), std=1.0)
)

def act(self, state):
action_probs = self.actor(state)
dist = Categorical(action_probs)
action = dist.sample()
return action.detach(), dist.log_prob(action).detach(), self.critic(state).detach()

def evaluate(self, state, action):
action_probs = self.actor(state)
dist = Categorical(action_probs)
return dist.log_prob(action), self.critic(state), dist.entropy()

ActorCritic クラスでは、方策(Policy)を表現するActorと、状態価値関数を表現するCriticの2つのサブネットワークを構築しています。

ネットワークの各線形層には、layer_init 関数を通じて直交初期化(Orthogonal Initialization)を適用しています。これは、初期の勾配の消失や爆発を防ぎ、学習の初期段階を極めて安定させるための一般的なテクニックです。特に、Actorの出力層の重み初期化においては std=0.01 を指定しています。これにより、学習開始時の各行動の選択確率をほぼ均等(エントロピーが最大の状態)に保ち、初期の探索を十分に促す工夫がなされています。また、Criticの出力層には std=1.0 を適用して、予測価値の初期スケールを維持します。

act メソッドは、現在の状態をネットワークに入力し、得られた確率から Categorical 分布を用いて行動をサンプリングします。この際、方策勾配法の特徴であるオンポリシーデータの収集を支援するため、サンプリングされた行動に加えてその対数確率(log_prob)とCriticによって計算された状態価値(state_value)をすべて勾配を遮断した状態(detach())で返します。

evaluate メソッドは、勾配更新(最適化)時に呼び出され、過去の探索時に得られた状態と行動に対応する、現在の最新ネットワークにおける対数行動確率、予測状態価値、および方策のエントロピー(dist.entropy())を再計算して返します。これは、クリップされた損失やエントロピーボーナスの算出に不可欠な役割を果たします。

Rollout Bufferの定義

環境との相互作用で得られたTTタイムステップのデータを一時的に保存するためのバッファクラスです。

以下のコードでは、エージェントが環境と相互作用して1つの学習区間を進める中で、収集した遷移データ(状態、行動、対数確率、報酬、終了判定、状態価値の予測値)を蓄積するための RolloutBuffer クラスを実装します。

class RolloutBuffer:
def __init__(self):
# 価値関数の予測値(values)も保存できるように追加
self.states, self.actions, self.logprobs, self.rewards, self.is_terminals, self.values = [], [], [], [], [], []

def clear(self):
self.states.clear(); self.actions.clear(); self.logprobs.clear(); self.rewards.clear(); self.is_terminals.clear(); self.values.clear()

RolloutBuffer は、PPOなどのオンポリシー方策勾配法で用いられるシンプルなデータ格納用のバッファクラスです。

初期化メソッド init では、状態(states)、行動(actions)、旧方策の対数行動確率(logprobs)、即時報酬(rewards)、ゲームが終了したかどうかを示すフラグ(is_terminals)、および旧ネットワークによる状態価値の予測値(values)を格納するリストを用意しています。

PPOはオンポリシーアルゴリズムであるため、一度収集したデータを用いて方策を複数エポック(K_epochs)にわたって更新した後は、その古いデータはすべて破棄する必要があります。そのため、更新の直後には clear メソッドが呼び出され、すべての蓄積データをクリアして次のサンプリングに備えます。これにより、メモリの肥大化を防ぎ、常に最新の方策で収集したデータのみが学習に利用されることを保証します。

PPOアルゴリズムのコア実装 (Algorithm 1)

クリップされた目的関数、価値関数の損失、エントロピーボーナスを組み合わせた最適化ステップを定義します。

以下のコードでは、PPO(Proximal Policy Optimization)の主要なアルゴリズムフローおよびネットワークパラメータの更新ロジックをカプセル化した PPO クラスを実装します。データのサンプリングに伴う行動選択処理に加え、一般化アドバンテージ推定(GAE)による目標リターンとアドバンテージの計算、およびクリップされた目的関数、価値関数損失、エントロピーボーナスを組み合わせた統合損失の最小化ステップが含まれています。

class PPO:
# ミニバッチサイズ(batch_size)とGAEのパラメータ(lam)を追加
def __init__(self, state_dim, action_dim, lr=3e-4, gamma=0.99, lam=0.95, K_epochs=10, eps_clip=0.2, batch_size=64):
self.gamma = gamma
self.lam = lam
self.eps_clip = eps_clip
self.K_epochs = K_epochs
self.batch_size = batch_size

self.buffer = RolloutBuffer()

self.policy = ActorCritic(state_dim, action_dim).to(device)
self.optimizer = torch.optim.Adam(self.policy.parameters(), lr=lr, eps=1e-5)

self.policy_old = ActorCritic(state_dim, action_dim).to(device)
self.policy_old.load_state_dict(self.policy.state_dict())
self.MseLoss = nn.MSELoss()

def select_action(self, state):
with torch.no_grad():
state = torch.FloatTensor(state).to(device)
action, action_logprob, state_value = self.policy_old.act(state)

self.buffer.states.append(state)
self.buffer.actions.append(action)
self.buffer.logprobs.append(action_logprob)
self.buffer.values.append(state_value) # 価値も保存
return action.item()

def update(self, next_state, next_done):
# テンソル化
old_states = torch.squeeze(torch.stack(self.buffer.states)).to(device)
old_actions = torch.squeeze(torch.stack(self.buffer.actions)).to(device)
old_logprobs = torch.squeeze(torch.stack(self.buffer.logprobs)).to(device)
values = torch.squeeze(torch.stack(self.buffer.values)).to(device)
rewards = torch.tensor(self.buffer.rewards, dtype=torch.float32).to(device)
dones = torch.tensor(self.buffer.is_terminals, dtype=torch.float32).to(device)

# 次の状態の価値を計算(GAE用)
with torch.no_grad():
next_state = torch.FloatTensor(next_state).to(device)
next_value = self.policy_old.critic(next_state).squeeze()

# --- GAE (Generalized Advantage Estimation) の計算 ---
advantages = torch.zeros_like(rewards).to(device)
lastgaelam = 0
for t in reversed(range(len(rewards))):
if t == len(rewards) - 1:
nextnonterminal = 1.0 - next_done
nextvalues = next_value
else:
# この実装のバッファ(事後方式)では、dones[t]がステップt終了時の終端フラグを表す
nextnonterminal = 1.0 - dones[t]
nextvalues = values[t+1]
delta = rewards[t] + self.gamma * nextvalues * nextnonterminal - values[t]
advantages[t] = lastgaelam = delta + self.gamma * self.lam * nextnonterminal * lastgaelam
returns = advantages + values

b_inds = np.arange(len(old_states))

# --- シャッフルを伴うミニバッチ学習 ---
for _ in range(self.K_epochs):
np.random.shuffle(b_inds)
for start in range(0, len(old_states), self.batch_size):
end = start + self.batch_size
mb_inds = b_inds[start:end]

logprobs, state_values, dist_entropy = self.policy.evaluate(old_states[mb_inds], old_actions[mb_inds])
state_values = torch.squeeze(state_values)

mb_advantages = advantages[mb_inds]
# ミニバッチ単位でのアドバンテージ正規化(学習の安定化)
mb_advantages = (mb_advantages - mb_advantages.mean()) / (mb_advantages.std() + 1e-8)

# --- 1. ポリシーサロゲート損失 (L^{CLIP}) の計算 ---
# 確率比 r_t(θ) = π_θ(a|s) / π_θold(a|s) を計算
# 対数の引き算は割り算と同値: exp(log(a) - log(b)) = a / b
ratios = torch.exp(logprobs - old_logprobs[mb_inds])

# クリップ前の本来の目的関数 (CPI)
surr1 = ratios * mb_advantages
# クリップ後の目的関数(epsilon = eps_clip で制限)
surr2 = torch.clamp(ratios, 1 - self.eps_clip, 1 + self.eps_clip) * mb_advantages

# PyTorchのオプティマイザは「最小化」を行うため、符号を反転して負の損失にする
loss_pi = -torch.min(surr1, surr2).mean()

# --- 2. 価値関数の二乗誤差損失 (L^{VF}) の計算 ---
# A2CのCritic Lossと同じく、予測価値(state_values)をターゲット(returns)に近づけるMSE
# 係数 c_1 = 0.5 を適用
loss_v = 0.5 * self.MseLoss(state_values, returns[mb_inds])

# --- 3. エントロピーボーナス (S) の計算 ---
# A2Cのエントロピー正則化と同じく、方策が局所解に陥るのを防ぐためのボーナス
# ボーナスなので、最小化する損失関数としてはマイナスをつける。係数 c_2 = 0.01 を適用
loss_ent = -0.01 * dist_entropy.mean()

# --- 損失の結合と最適化 ---
# L^{CLIP+VF+S} = L^{CLIP} + c_1 * L^{VF} - c_2 * S
loss = loss_pi + loss_v + loss_ent

self.optimizer.zero_grad()
loss.backward()
# 勾配爆発を防ぐクリッピング(目的関数のクリッピングとは別物)
nn.utils.clip_grad_norm_(self.policy.parameters(), 0.5)
self.optimizer.step()

self.policy_old.load_state_dict(self.policy.state_dict())
self.buffer.clear()

PPO クラスは、PPOアルゴリズムにおける「行動の選択」と「方策および価値ネットワークのパラメータ更新」のコアプロセスを担当します。

1. 行動の選択 (select_action)

エージェントが環境の中で探索を行う際、古い方策(policy_old)を用いて行動を決定します。with torch.no_grad() で勾配計算を無効にし、サンプリングされた行動や対数確率、状態価値予測を RolloutBufferself.buffer)に保存してから、ゲーム環境に渡すためにスカラ値に変換して返します。

2. GAE (Generalized Advantage Estimation) によるアドバンテージの計算

update メソッドでは、バッファに溜まった TT ステップ分のデータをテンソルに変換した後、一般化アドバンテージ推定(GAE)を用いて各ステップのアドバンテージ A^t\hat{A}_t と目標状態価値(予測ターゲット)である収益(Returns)を計算します。 数式で定義された以下の関係式: A^t=δt+(γλ)δt+1++(γλ)Tt1δT1\hat{A}_t = \delta_t + (\gamma\lambda)\delta_{t+1} + \dots + (\gamma\lambda)^{T-t-1}\delta_{T-1} where δt=rt+γV(st+1)V(st)\text{where } \delta_t = r_t + \gamma V(s_{t+1}) - V(s_t) は、コード内で reversed(range(len(rewards))) のループを用いて時系列の逆順から動的に計算されています。変数 deltaδt\delta_t に対応し、累積された lastgaelamA^t\hat{A}_t を表します。この時系列の逆順による実装は、二重ループを避けて計算を効率化する定石です。

3. ミニバッチを用いた複数エポックの最適化

PPOの特徴的な要素として、1つのエピソードのデータを複数回(K_epochs 回)再利用して学習を繰り返す点が挙げられます。バッファ内のデータインデックスをシャッフルした上で、指定した batch_size ごとに以下の損失(目的関数)を計算します。

  • 確率比(Probability Ratio)の算出rt(θ)=πθ(atst)πθold(atst)r_t(\theta) = \frac{\pi_\theta(a_t\vert{}s_t)}{\pi_{\theta_{old}}(a_t\vert{}s_t)} は、対数確率の差の指数をとる形で計算されます: ratios = torch.exp(logprobs - old_logprobs[mb_inds]) 対数における引き算 log(A)log(B)=log(A/B)\log(A) - \log(B) = \log(A/B) の性質を利用し、exp(log(A/B))=A/B\exp(\log(A/B)) = A/B となるため、この計算は数学的に確率比 rt(θ)r_t(\theta) と等価です。
  • ポリシー損失(サロゲート損失 LCLIPL^{CLIP}LCLIP(θ)=E^t[min(rt(θ)A^t,clip(rt(θ),1ϵ,1+ϵ)A^t)]L^{CLIP}(\theta) = \hat{\mathbb{E}}_t \left[ \min(r_t(\theta)\hat{A}_t, \text{clip}(r_t(\theta), 1-\epsilon, 1+\epsilon)\hat{A}_t) \right] に対応するコードは以下のように実装されています。 surr1 = ratios * mb_advantages surr2 = torch.clamp(ratios, 1 - self.eps_clip, 1 + self.eps_clip) * mb_advantages loss_pi = -torch.min(surr1, surr2).mean() 最適化(Adam)は最小化を目指すため、最大化を目的とする LCLIPL^{CLIP} にマイナスを掛け合わせて損失 loss_pi としています。これにより、確率比 rtr_t の極端な変動による方策の急激な変化にブレーキがかけられます。
  • 価値関数の二乗誤差損失 (LtVFL_t^{VF})loss_v = 0.5 * self.MseLoss(state_values, returns[mb_inds]) これは Critic ネットワークの予測精度を向上させ、収益目標に近づけるためのMSE(平均二乗誤差)です。係数 c1c_1 に相当する 0.5 が掛けられています。
  • エントロピーボーナス (SS)loss_ent = -0.01 * dist_entropy.mean() これは方策のランダム性を確保し、早期の局所解への収束(探索の枯渇)を防ぐ役割を持ちます。係数 c2c_20.01 としています。
  • 総合損失(統合目的関数 LtCLIP+VF+S(θ)L_t^{CLIP+VF+S}(\theta)LtCLIP+VF+S(θ)=E^t[LtCLIP(θ)c1LtVF(θ)+c2S[πθ](st)]L_t^{CLIP+VF+S}(\theta) = \hat{\mathbb{E}}_t \left[ L_t^{CLIP}(\theta) - c_1 L_t^{VF}(\theta) + c_2 S[\pi_\theta](s_t) \right] の最大化を最小化に変換した: loss = loss_pi + loss_v + loss_ent を計算し、誤差逆伝播(loss.backward())を実行します。この際、nn.utils.clip_grad_norm_ を用いて勾配のL2ノルムが 0.50.5 を超えないようクリッピング(Gradient Clipping)を施し、勾配爆発を抑止しています。

4. 本来のPPO(実用的実装)との異なる箇所

このシンプルなスクラッチ実装は、PPOのアルゴリズムを原理から理解するために設計された「概念モデル」であり、Stable Baselines3などの実用的なプロダクション用ライブラリで採用されている本来のPPOとは、主に以下の点で異なります。

  1. 並列環境(Vectorized Environments)の非対応: 本来のPPOは、複数の独立した環境(アクター)を並列で実行し、それぞれからデータを同時に収集(マルチスレッドやマルチプロセス処理)することでデータの多様性を確保します。本スクラッチ実装では、シングルプロセスで1つの環境から逐次的にデータを収集しています。
  2. 状態(Observation)と報酬(Reward)の正規化・スケーリング: 実用的な実装では、方策および価値ネットワークの入力を安定させるため、状態ベクトルを平均 00、分散 11 に自動的に正規化するラッパーや、報酬のスケールを動的に調整する報酬スケーリングが組み込まれています。本コードではこれらをスキップし、生の環境データをそのまま流しています。
  3. 価値関数(Critic)のクリッピング: 方策(Actor)の確率比のクリッピングと同様に、価値関数(Critic)の予測値 Vθ(st)V_\theta(s_t) に対しても、更新前の予測値から大きく逸脱しないようにクリッピングを適用する仕組み(Value Function Clipping)が、Stable Baselines3等ではデフォルトで有効になっています。本実装では、単純な MSE を用いて制限なしに最適化しています。
  4. 学習率の線形減衰(Learning Rate Decay): 学習が進行するにつれて(総タイムステップ数の消化状況に応じて)、学習率を初期値からゼロに向けて線形に減少させることが推奨されますが、本実装では固定値の lr=3e-4 を最後まで使用し続けています。

訓練ループ

OpenAI Gym(Gymnasium)の環境を使用して、学習ループを実行します。

以下のコードでは、これまでに定義した PPO エージェントと ActorCritic ネットワークを組み合わせて、GymnasiumのCartPole-v1環境でエージェントを学習させる訓練ループを構築します。エージェントが環境から状態を取得して行動を選択し、得られた遷移データをバッファへ蓄積し、一定ステップごとにPPOのパラメータ更新を実行する一連の流れを制御します。

env = gym.make("CartPole-v1")
state_dim = env.observation_space.shape[0]
action_dim = env.action_space.n
update_timestep = 2000

ppo_agent = PPO(state_dim, action_dim)

time_step = 0
max_training_timesteps = int(1e5)

state, _ = env.reset()
episode_score = 0
episode_count = 0
ppo_scores = []

print("--- PPO (スクラッチ) 学習開始 ---")
while time_step <= max_training_timesteps:
action = ppo_agent.select_action(state)
next_state, reward, done, truncated, _ = env.step(action)

episode_score += reward
ppo_agent.buffer.rewards.append(reward)
ppo_agent.buffer.is_terminals.append(done)

time_step += 1

if time_step % update_timestep == 0:
# GAE計算用に次の状態(next_state)と終了フラグ(done)を渡す
ppo_agent.update(next_state, done)

state = next_state

if done or truncated:
episode_count += 1
ppo_scores.append(episode_score)

if episode_count % 50 == 0:
print(f"PPO エピソード {episode_count}: スコア {episode_score}")

state, _ = env.reset()
episode_score = 0

print("--- PPO (スクラッチ) 学習完了 ---")

この訓練ループでは、Gymnasiumの代表的なタスクである CartPole-v1 環境を構築し、観測空間の次元数(state_dim = 4)と行動空間のサイズ(action_dim = 2)を取得して、PPO エージェントを初期化しています。

学習全体のステップ数は max_training_timesteps = 100000(10万タイムステップ)に制限されており、メインの while ループを通じてエージェントが各タイムステップで相互作用を繰り返します。

各ステップでは、エージェントの select_action を通じて行動を選択し、環境の env.step(action) を実行して、次の状態、即時報酬、およびエピソードの終了判定(done または truncated)を取得します。得られた報酬と終了判定は、即座にバッファ(ppo_agent.buffer)に追加されます。

time_step % update_timestep == 0(ここでは2,000ステップごと)に、エージェントの update メソッドが呼び上げられ、バッファに溜まった直近2,000ステップ分のデータを用いてGAEの計算および複数エポックにわたるパラメータのミニバッチ更新(最適化)を行います。

エピソードが終了(ポールが倒れるか、または最大報酬リミットに達したとき)した場合は、累積スコアを ppo_scores リストに保存し、環境を env.reset() で初期状態に戻して新しいエピソードを開始します。また、進捗状況の把握のため、50エピソードごとにその時点のエピソードスコアを標準出力にプリントします。

この実装では、PPOの論文で提案されたAlgorithm 1の核となる「データの収集」と「複数エポックでのミニバッチ更新(Clipped Objective)」のプロセスを明確に表現しています。

PPO(スクラッチ)の実行結果

--- PPO (厳密スクラッチ) 学習開始 ---
PPO エピソード 50: スコア 20.0
PPO エピソード 100: スコア 17.0
PPO エピソード 150: スコア 9.0
PPO エピソード 200: スコア 26.0
PPO エピソード 250: スコア 171.0
PPO エピソード 300: スコア 440.0
PPO エピソード 350: スコア 500.0
PPO エピソード 400: スコア 500.0
PPO エピソード 450: スコア 500.0
--- PPO (スクラッチ) 学習完了 ---

この学習ログから、初期の段階(例えばエピソード50や100など)では平均スコアが100未満と低い値にとどまっているのに対し、学習が進むにつれてエージェントの方策が効率的に改善されていることがわかります。エピソード250あたりからスコアが100を超えて急成長し、エピソード350以降は CartPole-v1 の最大可能ステップ報酬(上限値)である 500.0 を連続して維持するようになっています。これは、PPOが頑健に機能し、カートの上でポールを倒さずに完全にバランスを維持し続ける最適な制御行動を学習できたことを示しています。

比較のTRPOの実装

以下のコードでは、PPOの先行研究であり、より厳密な信頼領域(Trust Region)による単調改善の制約を課すTRPO(Trust Region Policy Optimization)の実装を示します。ここでは、強化学習ライブラリの拡張パッケージである sb3-contrib から TRPO を利用し、同一の環境(CartPole-v1)を学習させてその性能推移を記録するためのカスタムコールバッククラスを定義しています。

from sb3_contrib import TRPO
from stable_baselines3.common.callbacks import BaseCallback

# 50エピソードごとにスコアを出力し、リストに保存するカスタムコールバック
class ScoreCallback(BaseCallback):
def __init__(self, print_freq=50, verbose=0):
super().__init__(verbose)
self.print_freq = print_freq
self.episode_count = 0
self.episode_scores = []
self.current_score = 0

def _on_step(self) -> bool:
# SB3はデフォルトでベクトル化環境として処理するため [0] でアクセスします
self.current_score += self.locals["rewards"][0]

# エピソード終了判定
if self.locals["dones"][0]:
self.episode_count += 1
self.episode_scores.append(self.current_score)

# 50エピソード間隔で出力
if self.episode_count % self.print_freq == 0:
print(f"TRPO エピソード {self.episode_count}: スコア {self.current_score}")

self.current_score = 0
return True

# 環境とモデル of TRPO 初期化
env_trpo = gym.make("CartPole-v1")
model_trpo = TRPO("MlpPolicy", env_trpo, verbose=0)
score_callback = ScoreCallback(print_freq=50)

print("--- TRPO (Stable Baselines3) 学習開始 ---")
# PPOと同じ10万タイムステップで学習を実行
model_trpo.learn(total_timesteps=int(1e5), callback=score_callback)

# 学習完了後にスコアのリストを取得
trpo_scores = score_callback.episode_scores

このコードでは、Stable Baselines3の拡張パッケージ(sb3-contrib)に実装されている TRPO(Trust Region Policy Optimization)アルゴリズムを用いて、PPOと比較するための学習プロセスを構築しています。

強化学習の学習途中のスコアを記録・可視化するため、BaseCallback を継承したカスタムコールバッククラス ScoreCallback を定義しています。このコールバック内の _on_step メソッドは、シミュレーションが1ステップ進むたびに自動的に呼び出されます。

Stable Baselines3は内部で環境をベクトル化(並列化)して管理しているため、報酬の取得には self.locals["rewards"][0]、エピソードの終了判定(dones)には self.locals["dones"][0] のようにインデックス [0] を指定して、最初の並列環境のデータにアクセスします。エピソード終了時に累積された報酬和(スコア)を episode_scores リストに保存し、指定された頻度(print_freq=50、50エピソードごと)でログを標準出力にプリントします。

最後に、TRPO モデルを MlpPolicy(多層パーセプトロン方策)で初期化し、PPOと同じ総タイムステップ数である 100000 に設定して model_trpo.learn を呼び出し、作成したコールバックを引き渡して学習を開始しています。

TRPO(Stable Baselines3)の実行結果

--- TRPO (Stable Baselines3) 学習開始 ---
TRPO エピソード 50: スコア 23.0
TRPO エピソード 100: スコア 21.0
TRPO エピソード 150: スコア 16.0
TRPO エピソード 200: スコア 23.0
TRPO エピソード 250: スコア 45.0
TRPO エピソード 300: スコア 197.0
TRPO エピソード 350: スコア 188.0
TRPO エピソード 400: スコア 500.0
TRPO エピソード 450: スコア 500.0

TRPOの学習ログから、初期の段階(エピソード250以前)ではスコアが50.0以下と停滞気味であるのに対し、エピソード300あたりからスコアが向上し始め、最終的にはエピソード400付近でPPOと同様に最大可能報酬である500.0に達していることが確認できます。TRPOは信頼領域(Trust Region)内で方策の改善幅を制約する信頼性の高いアルゴリズムであるため、学習の途中で性能が崩壊しにくいという特徴を持っていますが、PPOに比べると初期の学習スピードがやや遅れる傾向が見られます。

スコアの可視化と比較

以下のコードでは、スクラッチで実装したPPOと、Stable Baselines3を用いて学習させたTRPOのエピソードスコアの推移を比較するため、matplotlibを用いて折れ線グラフとして可視化します。

import matplotlib.pyplot as plt

plt.figure(figsize=(10, 6))

# PPOとTRPOのスコアをプロット (alphaで少し透明にして重なりを見やすくします)
plt.plot(ppo_scores, label="PPO (Scratch)", alpha=0.7)
plt.plot(trpo_scores, label="TRPO (SB3-Contrib)", alpha=0.7)

plt.title("CartPole-v1: PPO vs TRPO Training Scores")
plt.xlabel("Episodes")
plt.ylabel("Score (Reward Sum)")
plt.legend()
plt.grid(True)
plt.show()

このコードでは、描画ライブラリである matplotlib.pyplot を用いて、スクラッチPPO(ppo_scores)とTRPO(trpo_scores)のエピソードスコアをプロットしています。グラフの重なりを見やすくするため、描画線の透明度(alpha=0.7)を設定し、各軸のラベル(xlabelylabel)や凡例(legend())を追加することで、学習スピードの差を視覚的に評価しやすくしています。

上記のコードを実行すると以下の結果が得られる。 スクラッチPPOとTRPOの比較 上のグラフ画像には、スクラッチPPOとStable Baselines3のTRPOの学習スコアを比較した結果が表示されています。グラフから確認できるように、スクラッチで実装したPPOはTRPOよりも早く安定した学習を達成しており、より少ないエピソード数(およそ350エピソード付近)で最大報酬である500.0に到達しています。TRPOは理論的に性能崩壊を防ぐ頑健な制約を課すため計算が複雑ですが、PPOは「クリップされたサロゲート目的関数」によって、よりシンプルな1次最適化のみで優れたデータ効率と素早い立ち上がりを実現できていることが視覚的にも確認できます。

Stable Baselines3による実装

強化学習の分野にも強力で使いやすいライブラリが存在します。その代表格が Stable Baselines3 (SB3) です。 SB3を使用すれば、何百行にも及ぶ複雑な実装を数行のコードに抽象化し、最適化された最新のPPOアルゴリズムをすぐに利用できます。

以下のコードでは、今回の解説で用いる代表的な強化学習ライブラリである Stable Baselines3 (SB3)、拡張アルゴリズムを提供する sb3-contrib、およびプロット用の matplotlib をインストールします。

!pip install stable-baselines3[extra] sb3-contrib matplotlib

このコマンドでは、Jupyter NotebookやGoogle Colabなどのセル上で、PPOアルゴリズムを含む定番の強化学習フレームワークである stable-baselines3[extra] をインストールしています。また、比較検証用にTRPOが同梱されている拡張機能パッケージの sb3-contrib と、学習スコアの可視化に必要な matplotlib も同時にセットアップします。

以下のコードでは、Stable Baselines3(SB3)に実装されている最適化済みの PPO および TRPO アルゴリズムを用いて、CartPole-v1 環境で同時に学習を行い、その学習履歴データを同じグラフ上にプロットして可視化・比較します。ライブラリならではの高度なラッパーやモデル初期化パラメータの設定方法を示します。

import gymnasium as gym
import matplotlib.pyplot as plt
from stable_baselines3 import PPO
from sb3_contrib import TRPO
from stable_baselines3.common.callbacks import BaseCallback

# 50エピソードごとにスコアを出力し、リストに保存するカスタムコールバック
class ScoreCallback(BaseCallback):
def __init__(self, algo_name, print_freq=50, verbose=0):
super().__init__(verbose)
self.algo_name = algo_name
self.print_freq = print_freq
self.episode_count = 0
self.episode_scores = []
self.current_score = 0

def _on_step(self) -> bool:
# 報酬を加算
self.current_score += self.locals["rewards"][0]

# エピソード終了判定
if self.locals["dones"][0]:
self.episode_count += 1
self.episode_scores.append(self.current_score)

# 指定エピソード間隔で出力
if self.episode_count % self.print_freq == 0:
print(f"{self.algo_name} エピソード {self.episode_count}: スコア {self.current_score}")

self.current_score = 0
return True

# 1. 環境の作成
env_ppo = gym.make("CartPole-v1")
env_trpo = gym.make("CartPole-v1")

# 2. モデルの初期化 (verbose=0 を指定して標準出力をサイレント化)
model_ppo = PPO(
"MlpPolicy",
env_ppo,
verbose=0,
learning_rate=0.0003,
n_steps=2048,
batch_size=64,
n_epochs=10,
clip_range=0.2
)
model_trpo = TRPO("MlpPolicy", env_trpo, verbose=0)

# 3. コールバックの初期化
callback_ppo = ScoreCallback(algo_name="PPO (SB3)", print_freq=50)
callback_trpo = ScoreCallback(algo_name="TRPO (SB3)", print_freq=50)

# 4. モデルの学習
print("--- PPO (Stable Baselines3) 学習開始 ---")
model_ppo.learn(total_timesteps=100000, callback=callback_ppo)

print("--- TRPO (Stable Baselines3) 学習開始 ---")
model_trpo.learn(total_timesteps=100000, callback=callback_trpo)

# 5. スコアの可視化と比較
plt.figure(figsize=(10, 6))

plt.plot(callback_ppo.episode_scores, label="PPO (Stable Baselines3)", alpha=0.7)
plt.plot(callback_trpo.episode_scores, label="TRPO (sb3-contrib)", alpha=0.7)

plt.title("CartPole-v1: PPO vs TRPO (Stable Baselines3)")
plt.xlabel("Episodes")
plt.ylabel("Score (Reward Sum)")
plt.legend()
plt.grid(True)
plt.show()

このコードでは、Stable Baselines3(SB3)ライブラリを用いた、非常に簡潔で高度に最適化された PPO と TRPO の実装を示しています。

ライブラリによる簡略化の強力な例として、環境構築後に PPO クラスをインスタンス化するだけで、モデル(方策ネットワーク)の構築、最適化アルゴリズム(Adam)の設定、そして各種パラメータ(learning_rate=0.0003clip_range=0.2batch_size=64、エポック数 n_epochs=10 など)の反映がバックグラウンドで自動的に行われます。同様に、TRPO もわずか1行でモデルが定義されます。

学習は model.learn(total_timesteps=100000) メソッドを呼び出すだけで自動実行されます。ここでは、各アルゴリズム(PPOとTRPO)の学習進度を同じ条件で追跡するために、アルゴリズム名を識別子(algo_name)として受け取るよう改良した ScoreCallback インスタンスをそれぞれ定義し、モデル学習時に callback 引数として渡しています。

学習完了後、コールバックの episode_scores からスコア履歴を取り出し、matplotlib を用いて両者のパフォーマンス(エピソード報酬の推移)を単一の図に描画して比較しています。

実行結果

--- PPO (Stable Baselines3) 学習開始 ---
PPO (SB3) エピソード 50: スコア 18.0
PPO (SB3) エピソード 100: スコア 55.0
PPO (SB3) エピソード 150: スコア 99.0
PPO (SB3) エピソード 200: スコア 86.0
PPO (SB3) エピソード 250: スコア 249.0
PPO (SB3) エピソード 300: スコア 500.0
PPO (SB3) エピソード 350: スコア 500.0
PPO (SB3) エピソード 400: スコア 500.0
--- TRPO (Stable Baselines3) 学習開始 ---
TRPO (SB3) エピソード 50: スコア 13.0
TRPO (SB3) エピソード 100: スコア 21.0
TRPO (SB3) エピソード 150: スコア 10.0
TRPO (SB3) エピソード 200: スコア 15.0
TRPO (SB3) エピソード 250: スコア 29.0
TRPO (SB3) エピソード 300: スコア 146.0
TRPO (SB3) エピソード 350: スコア 500.0
TRPO (SB3) エピソード 400: スコア 500.0
TRPO (SB3) エピソード 450: スコア 500.0

上記のコードを実行すると以下の結果が得られる。 Stable Baselines3によるPPOとTRPOの比較 上のグラフ画像には、Stable Baselines3によるPPOとTRPOの学習スコアの比較グラフが表示されます。グラフから確認できるように、高度に最適化されたPPOがTRPO(350エピソード付近)よりもさらに早いエピソード(およそ300エピソード付近)で最大スコアである500.0に到達し、その後も非常に安定した学習を達成していることが確認できます。これは、スクラッチ実装による比較と同様の傾向を示しており、PPOの頑健性と学習の立ち上がりスピードの優秀性を改めて裏付けています。

まとめ

本記事では、現代の強化学習(DRL)におけるデファクトスタンダードである PPO (Proximal Policy Optimization) について、その理論から実装まで詳しく解説しました。

PPOは、先行するTRPOの「信頼領域」に基づく単調改善の安定性を維持しつつ、複雑な二次最適化の計算を「クリップされたサロゲート目的関数」に置き換えることで、圧倒的な実装の簡潔さと優れたデータ効率を実現した画期的なアルゴリズムです。

本記事を通じて、以下の内容を学習・実践しました:

  • PPOのコア理論の理解:ポリシー更新の急激な変化にブレーキをかける「クリッピング」の数式や、アドバンテージを安定して推定する GAE(Generalized Advantage Estimation) の仕組みを学びました。
  • Actor-Criticのスクラッチ実装:PyTorchを用いて、直交重み初期化を適用した ActorCritic ネットワークから、バッファ管理の RolloutBuffer、GAEやサロゲート損失の最小化ステップを担う PPO クラスまでを自力で構築しました。
  • 実用ライブラリとの比較検証:スクラッチ実装による検証と、強化学習の標準ライブラリである Stable Baselines3 を用いた実装の双方で、TRPOよりもPPOの方が学習の立ち上がりが早く、極めて安定して最大報酬に到達できることを比較プロットで確認しました。

PPOは、ゲームAIの開発からロボット制御、さらには大規模言語モデル(LLM)の人間フィードバックによる強化学習(RLHF)の基盤技術に至るまで、極めて幅広い領域で活用されています。本記事をきっかけに、ぜひご自身の強化学習プロジェクトやモデルのチューニングにPPOを活用してみてください。

本記事の文章・構成の一部に生成AIを使用しています。