DQN(Deep Q-Network)の理論と実装

2013年、DeepMindはAtariゲームを人間レベルで攻略するAIを発表し、世界を驚かせました。ブレイクアウト、ポン、スペースインベーダーなど、49種類のゲームをピクセルデータのみから学習し、そのうち29種類で人間を上回る性能を達成したのです。このAIの心臓部がDQN(Deep Q-Network) です。

DQNは「行動の価値を深層ニューラルネットワークで推定する」という一見シンプルなアイデアですが、ニューラルネットワークとQ学習を安定的に組み合わせるために、経験再生(Experience Replay)ターゲットネットワークという2つの革新的なテクニックを導入しました。

これまでの記事で解説した方策勾配法が「方策を直接最適化する」のに対し、DQNは「行動の価値を正確に推定できれば、最適な方策は自動的に得られる」という価値ベースのアプローチをとります。

DQNを理解すると、以下のような応用が開けます。

  • ゲームAI: Atariゲーム、ボードゲームでのAIプレイヤー
  • 推薦システム: ユーザーの長期的な満足度を最大化する推薦
  • ネットワーク制御: 通信ネットワークのルーティング最適化
  • 在庫管理: 動的な在庫補充の最適化

本記事の内容

  • Q学習の基礎とベルマン最適方程式
  • 関数近似のQ学習の課題
  • DQNの2つのイノベーション: 経験再生とターゲットネットワーク
  • PyTorchによるCartPole環境での実装

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

Q学習の基礎

行動価値関数

強化学習の目標は、累積報酬を最大化する方策を見つけることです。ここでまず直感を掴みましょう。

日常生活のアナロジーで考えると、Q関数とは「レストランの口コミスコア」に似ています。あなたが旅行先(状態 $s$)で食事をしようとしたとき、候補のレストランそれぞれ(行動 $a$)について「このレストランに入ったら、前菜からデザートまでトータルでどれだけ満足できるか」を数値化したものがQ値です。重要なのは、「今の一皿の美味しさ」だけでなく、「コース全体を通した満足度」を評価する点です。強化学習でも同様に、Q関数は目先の報酬だけでなく、将来にわたる報酬の合計を見積もります。

行動価値関数(Q関数)は、状態 $s$ で行動 $a$ をとり、その後は方策 $\pi$ に従ったときの期待累積報酬を表します。

$$ \begin{equation} Q^\pi(s, a) = \mathbb{E}_\pi\left[\sum_{t=0}^{\infty} \gamma^t r_t \,\middle|\, s_0 = s, a_0 = a\right] \end{equation} $$

ここで $\gamma$ は割引率($0 \le \gamma < 1$)で、将来の報酬をどれだけ重視するかを決めるパラメータです。$\gamma = 0$ なら目先の報酬だけを見る近視眼的な評価、$\gamma$ が 1 に近づくほど遠い将来まで考慮した評価になります。たとえばチェスの序盤で駒を1つ犠牲にする手(目先の報酬はマイナス)でも、数手先でチェックメイトに至る(将来の報酬が大きい)なら、$\gamma$ が十分大きければQ値は高く評価されます。

最適方策 $\pi^*$ に対応する最適行動価値関数 $Q^*(s, a)$ が分かれば、最適方策は単純に各状態で $Q^*$ を最大化する行動を選ぶだけです。

$$ \pi^*(s) = \arg\max_a Q^*(s, a) $$

つまり、$Q^*$ を正確に推定できれば、方策を陽にモデル化する必要がないのです。これが価値ベースアプローチの核心です。方策勾配法では「どの行動をどの確率で選ぶか」という方策そのものをニューラルネットワークで表現しましたが、価値ベースでは「各行動にどれだけの価値があるか」を推定し、最も価値が高い行動をそのまま選びます。方策を直接最適化する方法と価値を推定する方法は、強化学習の二大アプローチといえます。

ベルマン最適方程式

$Q^*$ はベルマン最適方程式を満たします。

$$ \begin{equation} Q^*(s, a) = \mathbb{E}\left[r + \gamma \max_{a’} Q^*(s’, a’) \,\middle|\, s, a\right] \end{equation} $$

この式は「最適な行動価値 = 即時報酬 + 将来の最適な行動価値の割引値」を表しています。現在の報酬と次の状態での最良の選択を考慮した再帰的な定義です。

もう少し丁寧に読み解きましょう。右辺の $r$ は現在の行動 $a$ で得られる即時報酬です。次に遷移先の状態 $s’$ では、将来に向けて最も価値の高い行動 $a’ = \arg\max_{a’} Q^*(s’, a’)$ を選ぶことが前提です。その最大Q値に割引率 $\gamma$ を掛けて将来分とし、即時報酬 $r$ に足し合わせます。この再帰的な構造は、動的計画法の原理「最適方策の部分方策もまた最適である」を反映しています。つまり、今の行動が最適であるためには、次の状態以降も最適に行動し続ける必要があるという、当然だが強力な原理です。

ベルマン方程式を反復的に解くのがQ学習です。状態 $s$ で行動 $a$ をとり、報酬 $r$ と次状態 $s’$ を観測したとき、Q値を以下のように更新します。

$$ Q(s, a) \leftarrow Q(s, a) + \alpha \left[r + \gamma \max_{a’} Q(s’, a’) – Q(s, a)\right] $$

$\alpha$ は学習率です。角括弧の中身を分解して見てみましょう。$r + \gamma \max_{a’} Q(s’, a’)$ はTDターゲットと呼ばれ、「実際に得た報酬 + 次状態からの推定値」であり、現在のQ値よりも1ステップ分だけ「真の値に近い」推定量です。一方、$Q(s, a)$ は現在の推定値です。この2つの差、すなわち

$$ \delta = r + \gamma \max_{a’} Q(s’, a’) – Q(s, a) $$

TD誤差(Temporal Difference Error)と呼びます。TD誤差が正であれば「予想より良い結果だった」ことを意味し、Q値を増加させます。逆にTD誤差が負であれば「予想より悪い結果だった」ことを意味し、Q値を減少させます。学習率 $\alpha$ はこの修正量の大きさを制御します。$\alpha$ が大きすぎると更新が不安定になり、小さすぎると収束が遅くなるため、適切な値の設定が重要です。

ε-グリーディ方策

Q学習では、現在のQ値に基づいて行動を選択しますが、常に最善の行動だけを選ぶと(探索が不足して)新しい戦略を発見できません。ε-グリーディ方策は、確率 $\epsilon$ でランダムな行動を選び、確率 $1-\epsilon$ で現在のQ値が最大の行動を選びます。

$$ a = \begin{cases} \text{ランダム行動} & \text{確率 } \epsilon \\ \arg\max_a Q(s, a) & \text{確率 } 1-\epsilon \end{cases} $$

$\epsilon$ を学習の進行とともに減衰させる(最初は多く探索し、徐々に活用を増やす)のが一般的です。

探索と活用のトレードオフは強化学習全般に共通する重要な概念です。直感的には、新しい街に引っ越してきたばかりのときは、様々なレストランを試す(探索)のが合理的です。しかし十分な情報が集まったら、気に入った店に通う(活用)方が効率的です。ε-グリーディはこのバランスを $\epsilon$ という1つのパラメータで調整するシンプルかつ効果的な方法です。

Q学習の基礎を理解したところで、次にニューラルネットワークでQ関数を近似する際の課題を見ていきましょう。

関数近似のQ学習の課題

テーブルからニューラルネットワークへ

状態空間が小さい場合、Q値をテーブル(表)として保持できます。たとえば4×4のグリッドワールドであれば状態は16個、行動が4方向なら、$16 \times 4 = 64$ 個のQ値を格納する表を用意すれば十分です。しかし、Atariゲームのようにピクセル画像が状態の場合はどうでしょうか。$210 \times 160$ ピクセルのRGB画像では、理論上の状態数は $256^{210 \times 160 \times 3}$ 通りにもなります。テーブル表現は到底不可能です。

ここで自然に思いつくのが、ニューラルネットワーク $Q_\theta(s, a)$ でQ値を近似する(関数近似)というアプローチです。状態を入力として受け取り、各行動に対するQ値を出力するニューラルネットワークを構築すれば、テーブルに格納できない膨大な状態空間でも、未知の状態に対するQ値を汎化によって推定できます。

しかし、ニューラルネットワークとQ学習を単純に組み合わせると、以下の2つの深刻な問題が生じます。

問題1: データの相関: エージェントが環境を探索して得る経験 $(s_t, a_t, r_t, s_{t+1})$ は時系列データであり、連続するサンプル間に強い相関があります。ニューラルネットワークの学習はi.i.d.(独立同分布)のデータを前提としているため、相関のあるデータで学習すると過学習や発振が起きます。

問題2: 非定常なターゲット: Q値の更新ターゲット $r + \gamma \max_{a’} Q_\theta(s’, a’)$ には、更新対象のネットワーク $Q_\theta$ 自身が含まれています。パラメータを更新するとターゲットも変化するため、追いかけっこ(moving target problem)が発生し、学習が収束しません。これは、走り高跳びの練習でバーの高さが跳ぶたびに勝手に変わるようなものです。目標が動き続けるため、いつまで経っても安定した結果に到達できません。

教師あり学習であれば、正解ラベルは固定されています。画像分類で「猫」というラベルが学習中に変わることはありません。しかしQ学習ではターゲットがネットワーク自身に依存しているため、パラメータを更新するたびに「正解」が変わってしまいます。この本質的な違いが、ニューラルネットワークとQ学習の組み合わせを困難にしていました。

これらの2つの問題を同時に解決し、ニューラルネットワークによるQ学習を実用レベルに引き上げたのがDQNです。

DQNの2つのイノベーション

経験再生(Experience Replay)

経験再生のアイデアは直感的に理解できます。試験勉強に例えると、教科書を最初から順に1回読むだけよりも、重要なポイントをフラッシュカードにまとめ、カードをシャッフルして繰り返し復習する方が記憶が定着します。フラッシュカードをシャッフルすることで「第3章の次は必ず第4章」という順序の偏りがなくなり、各トピックを独立に学べます。

経験再生は、エージェントの経験をリプレイバッファ(replay buffer)$\mathcal{D}$ に蓄え、学習時にランダムにサンプリングする手法です。

エージェントが経験 $(s_t, a_t, r_t, s_{t+1}, \text{done}_t)$ を得るたびに $\mathcal{D}$ に追加し、パラメータ更新時には $\mathcal{D}$ からランダムにミニバッチをサンプリングします。

$$ (s, a, r, s’, d) \sim \text{Uniform}(\mathcal{D}) $$

経験再生の効果は以下の3つです。

相関の除去: ランダムサンプリングにより、ミニバッチ内のサンプル間の時系列的相関が大幅に低減されます。i.i.d.に近い学習データが得られるため、ニューラルネットワークの学習が安定します。

データ効率の向上: 1つの経験を複数回の学習に使い回せるため、サンプル効率が向上します。方策勾配法(on-policy)では経験を1回使って捨てますが、DQN(off-policy)では過去の経験を再利用できます。

非定常性の緩和: 様々な時期の経験が混在するため、特定の時期のデータに偏った学習を避けられます。

実装上の注意点として、リプレイバッファの容量 $|\mathcal{D}|$ はハイパーパラメータです。容量が小さすぎると古い経験がすぐに上書きされ、多様なデータが失われます。逆に容量が大きすぎると、古くてQ値の推定精度が低かった時期の経験が長く残り、学習の効率が下がる場合があります。一般的には $10^4$ から $10^6$ 程度の値が使われます。

ターゲットネットワーク

ターゲットネットワークは、Q値の更新ターゲットを計算するために、パラメータが固定された別のネットワーク $Q_{\theta^-}$ を使う手法です。先ほどの走り高跳びのアナロジーで言えば、バーの高さを一定期間固定するということです。バーが固定されていれば、その高さを目標にフォームを調整できます。十分な練習ができたら、バーの高さを更新します。

具体的には、学習用のQネットワーク $Q_\theta$(オンラインネットワーク)とは別に、同じ構造を持つもう1つのネットワーク $Q_{\theta^-}$(ターゲットネットワーク)を用意します。TDターゲットの計算にはターゲットネットワークを使い、勾配降下法でパラメータを更新するのはオンラインネットワークのみです。

DQNの損失関数は

$$ \begin{equation} \mathcal{L}(\theta) = \mathbb{E}_{(s,a,r,s’,d) \sim \mathcal{D}}\left[\left(r + \gamma (1-d) \max_{a’} Q_{\theta^-}(s’, a’) – Q_\theta(s, a)\right)^2\right] \end{equation} $$

$\theta^-$ はターゲットネットワークのパラメータで、$C$ ステップごとに $\theta^- \leftarrow \theta$ と更新(ハードアップデート)します。あるいは、ソフトアップデート $\theta^- \leftarrow \tau \theta + (1-\tau)\theta^-$($\tau \ll 1$)を使う方法もあります。

損失関数を展開して見てみましょう。まず、1つのサンプル $(s, a, r, s’, d)$ に対する損失は以下の通りです。

$$ L_i(\theta) = \left(y_i – Q_\theta(s, a)\right)^2 $$

ここで $y_i$ がTDターゲットで、以下のように定義されます。

$$ y_i = r + \gamma (1 – d) \max_{a’} Q_{\theta^-}(s’, a’) $$

$d$ はエピソード終了フラグで、$d = 1$(終了)のとき将来の項がゼロになります。これは、ゲームオーバーになった後に将来の報酬は存在しないことを反映しています。$y_i$ の計算にターゲットネットワーク $Q_{\theta^-}$ を使うことで、パラメータ $\theta$ の更新がターゲットに影響しないようにしています。

ターゲットネットワークにより、TDターゲットが $C$ ステップの間固定されるため、移動ターゲット問題が緩和され、学習が安定します。ハードアップデートでは $C$ ステップごとにターゲットが不連続に変化しますが、ソフトアップデートでは毎ステップ少しずつターゲットが変化するため、より滑らかな学習が期待できます。DeepMindの原論文ではハードアップデートが使われましたが、後続の研究ではソフトアップデートも広く使われています。

DQNアルゴリズムの全体像

ここまでの要素を統合した、DQNの学習アルゴリズムの流れを整理しておきましょう。

  1. リプレイバッファ $\mathcal{D}$ を初期化する
  2. Qネットワーク $Q_\theta$ とターゲットネットワーク $Q_{\theta^-}$ を同じ重みで初期化する
  3. 各エピソードで以下を繰り返す – 状態 $s$ に対してε-グリーディで行動 $a$ を選択する – 環境から報酬 $r$ と次状態 $s’$ を受け取る – 経験 $(s, a, r, s’, d)$ をリプレイバッファに保存する – リプレイバッファからミニバッチをサンプリングする – TDターゲットを計算し、損失関数を最小化するように $\theta$ を更新する
  4. $C$ ステップごとに $\theta^- \leftarrow \theta$ でターゲットネットワークを更新する

この手順が、Q学習を大規模問題に適用可能にしたDQNの核心です。それでは、PyTorchで実装してみましょう。

PyTorchによるDQNの実装

import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np
import matplotlib.pyplot as plt
import gymnasium as gym
from collections import deque
import random

torch.manual_seed(42)
random.seed(42)
np.random.seed(42)

# --- Qネットワーク ---
class QNetwork(nn.Module):
    def __init__(self, state_dim, action_dim, hidden_dim=128):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(state_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, action_dim),
        )

    def forward(self, state):
        return self.net(state)

# --- リプレイバッファ ---
class ReplayBuffer:
    def __init__(self, capacity=10000):
        self.buffer = deque(maxlen=capacity)

    def push(self, state, action, reward, next_state, done):
        self.buffer.append((state, action, reward, next_state, done))

    def sample(self, batch_size):
        batch = random.sample(self.buffer, batch_size)
        states, actions, rewards, next_states, dones = zip(*batch)
        return (np.array(states), np.array(actions), np.array(rewards),
                np.array(next_states), np.array(dones, dtype=np.float32))

    def __len__(self):
        return len(self.buffer)

# --- DQNエージェント ---
class DQNAgent:
    def __init__(self, state_dim, action_dim, lr=1e-3, gamma=0.99,
                 epsilon_start=1.0, epsilon_end=0.01, epsilon_decay=500,
                 target_update=10, buffer_size=10000, batch_size=64):
        self.action_dim = action_dim
        self.gamma = gamma
        self.batch_size = batch_size
        self.target_update = target_update
        self.epsilon_start = epsilon_start
        self.epsilon_end = epsilon_end
        self.epsilon_decay = epsilon_decay
        self.steps = 0

        self.q_net = QNetwork(state_dim, action_dim)
        self.target_net = QNetwork(state_dim, action_dim)
        self.target_net.load_state_dict(self.q_net.state_dict())
        self.optimizer = optim.Adam(self.q_net.parameters(), lr=lr)
        self.buffer = ReplayBuffer(buffer_size)

    def get_epsilon(self):
        return self.epsilon_end + (self.epsilon_start - self.epsilon_end) * \
            np.exp(-self.steps / self.epsilon_decay)

    def select_action(self, state):
        self.steps += 1
        if random.random() < self.get_epsilon():
            return random.randrange(self.action_dim)
        with torch.no_grad():
            state_t = torch.FloatTensor(state).unsqueeze(0)
            q_values = self.q_net(state_t)
            return q_values.argmax(dim=1).item()

    def update(self):
        if len(self.buffer) < self.batch_size:
            return None

        states, actions, rewards, next_states, dones = \
            self.buffer.sample(self.batch_size)

        states_t = torch.FloatTensor(states)
        actions_t = torch.LongTensor(actions).unsqueeze(1)
        rewards_t = torch.FloatTensor(rewards)
        next_states_t = torch.FloatTensor(next_states)
        dones_t = torch.FloatTensor(dones)

        # 現在のQ値
        q_values = self.q_net(states_t).gather(1, actions_t).squeeze(1)

        # ターゲットQ値(ターゲットネットワークを使用)
        with torch.no_grad():
            next_q_values = self.target_net(next_states_t).max(dim=1)[0]
            target = rewards_t + self.gamma * (1 - dones_t) * next_q_values

        # Huber損失(外れ値に頑健)
        loss = nn.functional.smooth_l1_loss(q_values, target)

        self.optimizer.zero_grad()
        loss.backward()
        nn.utils.clip_grad_norm_(self.q_net.parameters(), 1.0)
        self.optimizer.step()

        return loss.item()

    def update_target(self):
        self.target_net.load_state_dict(self.q_net.state_dict())

ReplayBuffer はdequeを使った固定サイズのバッファです。容量を超えると古い経験が自動的に削除されます。DQNAgent はε-グリーディ方策(εは指数減衰)、経験再生、ターゲットネットワークの3要素を統合しています。

コードのポイントをいくつか補足します。QNetwork は3層の全結合ニューラルネットワークで、入力が状態ベクトル、出力が各行動に対するQ値です。CartPole環境では状態が4次元(カート位置、カート速度、ポール角度、ポール角速度)、行動が2種類(左に押す、右に押す)なので、入力4次元・出力2次元のネットワークになります。

select_action メソッドでは、ε-グリーディ方策を実装しています。get_epsilon() は現在のステップ数に応じて $\epsilon$ を指数減衰させます。具体的には $\epsilon = \epsilon_{\text{end}} + (\epsilon_{\text{start}} – \epsilon_{\text{end}}) \cdot e^{-\text{steps}/\epsilon_{\text{decay}}}$ という式です。

update メソッドが学習の心臓部です。gather(1, actions_t) は、各状態で実際に選んだ行動に対応するQ値だけを取り出す操作です。たとえばバッチ内のある状態で行動0を選んでいた場合、出力ベクトル $[Q(s, 0), Q(s, 1)]$ から $Q(s, 0)$ を抽出します。

損失関数にはHuber損失(smooth_l1_loss)を使用しています。通常のMSE(平均二乗誤差)は大きなTD誤差に対して勾配が爆発しやすいですが、Huber損失は閾値 $\delta = 1$ を境に以下のように切り替わります。

$$ L_\delta(x) = \begin{cases} \frac{1}{2}x^2 & |x| \le \delta \\ \delta(|x| – \frac{1}{2}\delta) & |x| > \delta \end{cases} $$

小さな誤差に対してはMSEと同じく二次関数的に振る舞い、大きな誤差に対してはL1損失的に振る舞って勾配を制限します。これにより、外れ値的な大きなTD誤差に引っ張られてパラメータが大きく更新される事態を防ぎます。

また、clip_grad_norm_ で勾配のノルムを1.0にクリッピングしている点も重要です。勾配クリッピングは勾配爆発を防ぐ追加の安全策として機能します。

# --- 学習ループ ---
env = gym.make('CartPole-v1')
state_dim = env.observation_space.shape[0]
action_dim = env.action_space.n

agent = DQNAgent(state_dim, action_dim)
n_episodes = 500
episode_rewards = []
losses = []

for episode in range(n_episodes):
    state, _ = env.reset(seed=episode)
    total_reward = 0

    done = False
    while not done:
        action = agent.select_action(state)
        next_state, reward, terminated, truncated, _ = env.step(action)
        done = terminated or truncated

        agent.buffer.push(state, action, reward, next_state, float(done))
        loss = agent.update()
        if loss is not None:
            losses.append(loss)

        state = next_state
        total_reward += reward

    # ターゲットネットワークの更新
    if (episode + 1) % agent.target_update == 0:
        agent.update_target()

    episode_rewards.append(total_reward)

    if (episode + 1) % 50 == 0:
        recent = episode_rewards[-50:]
        print(f'Episode [{episode+1}/{n_episodes}] '
              f'Avg: {np.mean(recent):.1f} Eps: {agent.get_epsilon():.3f}')

env.close()

学習ループの構造を確認しましょう。各エピソードでは環境をリセットし、エピソードが終了するまで「行動選択→環境ステップ→経験保存→パラメータ更新」を繰り返します。CartPole-v1ではポールが倒れる(terminated)か500ステップに達する(truncated)とエピソードが終了します。ターゲットネットワークは10エピソードごとにハードアップデートで同期しています。50エピソードごとに直近50エピソードの平均報酬と現在のεを表示するので、学習の進捗を確認できます。報酬がCartPole-v1の最大値である500に近づき、εが0.01近くに減衰していれば、学習がうまく進んでいるといえます。

続いて、学習結果を可視化して、DQNの学習特性を分析してみましょう。

# --- 結果の可視化 ---
fig, axes = plt.subplots(1, 3, figsize=(16, 5))

# (a) 学習曲線
ax = axes[0]
window = 20
smooth = np.convolve(episode_rewards,
                      np.ones(window)/window, mode='valid')
ax.plot(smooth, 'b-', linewidth=2)
ax.set_xlabel('Episode', fontsize=12)
ax.set_ylabel('Episode Reward', fontsize=12)
ax.set_title('DQN Learning Curve', fontsize=13)
ax.axhline(500, color='red', linestyle='--', alpha=0.5)
ax.grid(True, alpha=0.3)

# (b) εの減衰
ax = axes[1]
eps_values = [agent.epsilon_end + (agent.epsilon_start - agent.epsilon_end)
              * np.exp(-i / agent.epsilon_decay) for i in range(10000)]
ax.plot(eps_values, 'g-', linewidth=2)
ax.set_xlabel('Step', fontsize=12)
ax.set_ylabel('$\\epsilon$', fontsize=12)
ax.set_title('$\\epsilon$-greedy Decay', fontsize=13)
ax.grid(True, alpha=0.3)

# (c) TD損失の推移
ax = axes[2]
if len(losses) > 100:
    window_loss = 100
    loss_smooth = np.convolve(losses,
                               np.ones(window_loss)/window_loss,
                               mode='valid')
    ax.plot(loss_smooth, 'r-', linewidth=1.5)
ax.set_xlabel('Update Step', fontsize=12)
ax.set_ylabel('TD Loss', fontsize=12)
ax.set_title('TD Loss during Training', fontsize=13)
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('dqn_result.png', dpi=150, bbox_inches='tight')
plt.show()

この可視化から、DQNの学習特性を確認できます。

  1. 左図(学習曲線): DQNの報酬推移です。学習初期はε-グリーディのランダム探索が多いため報酬が低いですが、εの減衰とともにQ値の推定が改善され、報酬が上昇しています

  2. 中央図(εの減衰): εが指数的に減衰する様子です。学習初期は $\epsilon \approx 1$ でほぼランダムに行動し(探索重視)、徐々に $\epsilon_{\text{end}} = 0.01$ に近づいてQ値に基づく行動を選ぶようになります(活用重視)

  3. 右図(TD損失): TD損失の推移です。学習初期は損失が大きく不安定ですが、ターゲットネットワークの更新と経験再生の効果により、徐々に安定化していきます

DQNの限界と発展手法

DQNは深層強化学習の礎を築きましたが、いくつかの既知の限界があります。これらを理解することは、より高度な手法への橋渡しとなります。

Q値の過大推定問題

DQNでは次状態のQ値を $\max_{a’} Q_{\theta^-}(s’, a’)$ で計算します。$\max$ 演算子は推定誤差が正方向にある行動を選びやすいため、Q値が系統的に過大推定される傾向があります。

直感的には、サイコロの出目の最大値の期待値を考えてみてください。1個のサイコロの期待値は3.5ですが、2個振って大きい方を取ると期待値は約4.47になります。行動が多いほど(サイコロの数が増えるほど)、推定誤差のうち最も楽観的なものが選ばれやすくなるのです。

この問題を解決したのがDouble DQNです。行動の選択にオンラインネットワーク $Q_\theta$ を使い、その行動の価値評価にターゲットネットワーク $Q_{\theta^-}$ を使うことで、選択と評価を分離します。

$$ y_i = r + \gamma (1-d) \, Q_{\theta^-}\!\left(s’, \arg\max_{a’} Q_\theta(s’, a’)\right) $$

行動の選択と評価に異なるネットワークを使うことで、過大推定のバイアスが軽減されます。

経験の優先度付け

標準的なDQNではリプレイバッファから一様ランダムにサンプリングしますが、すべての経験が等しく有用とは限りません。TD誤差が大きい経験、すなわち「予想と現実のギャップが大きかった」経験は、学習にとってより価値のある情報を含んでいます。

Prioritized Experience Replay(優先度付き経験再生)は、TD誤差の絶対値 $|\delta_i|$ に基づいてサンプリング確率を設定します。

$$ P(i) = \frac{p_i^\alpha}{\sum_k p_k^\alpha}, \quad p_i = |\delta_i| + \epsilon $$

$\alpha$ は優先度の強さを制御するハイパーパラメータで、$\alpha = 0$ なら一様ランダム、$\alpha = 1$ なら完全に優先度に従ったサンプリングになります。$\epsilon$ は小さな正の定数で、TD誤差がゼロの経験にもわずかなサンプリング確率を与えます。ただし、優先度に基づくサンプリングは分布の偏りを生むため、重要度サンプリングによる補正が必要です。

Dueling Network

行動価値 $Q(s, a)$ を状態価値 $V(s)$(その状態にいること自体の価値)とアドバンテージ $A(s, a)$(その行動がどれだけ平均より優れているか)に分解するアーキテクチャも提案されています。

$$ Q(s, a) = V(s) + A(s, a) – \frac{1}{|\mathcal{A}|}\sum_{a’} A(s, a’) $$

状態によっては「どの行動を選んでも結果がほとんど変わらない」場合があります。そのような状態では行動ごとの差(アドバンテージ)は小さく、状態価値の推定が支配的になります。Dueling Networkはこの構造を明示的にネットワークに組み込むことで、特に行動間の差が小さい状態でのQ値推定精度を向上させます。

これらの発展手法は、DQNの基本構造を維持しつつ特定の弱点を補うものです。2015年のNature論文以降、Rainbow DQN(2017年)がこれらの改良を統合し、Atariゲームで大幅な性能向上を達成しました。

まとめ

本記事では、DQN(Deep Q-Network)の理論と実装について解説しました。

  • Q学習はベルマン最適方程式に基づいてQ値を反復更新し、最適方策を間接的に学習する
  • 経験再生はデータの相関を除去し、サンプル効率を向上させる
  • ターゲットネットワークはTDターゲットを安定化し、学習の発散を防ぐ
  • ε-グリーディ方策は探索と活用のバランスを制御する
  • DQNは価値ベースのアプローチであり、方策勾配法とは相補的な関係にある

次のステップとして、以下の記事も参考にしてください。