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

Q学習は強力なアルゴリズムですが、状態空間が大きい(あるいは連続的な)問題ではQ値テーブルが扱えません。DQN(Deep Q-Network)はDeepMindが2013年に提案した手法で、Q値関数をニューラルネットワークで近似することでこの問題を解決しました。Atariゲームで人間を超える性能を達成し、深層強化学習の幕開けとなった画期的な手法です。

本記事の内容

  • Q学習から関数近似への拡張
  • DQNの2つの安定化技法(Experience Replay, Target Network)
  • 損失関数の設計
  • DQNのアルゴリズム
  • CartPole環境でのPython実装

前提知識

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

Q学習の復習と限界

テーブル型Q学習

Q学習の更新則は

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

この方法では $Q(s, a)$ を状態-行動ペアごとにテーブルとして保持します。

限界

  • 状態空間が大きい場合: 状態数が $10^6$ を超えると、すべての $(s, a)$ ペアを訪問して学習するのは非現実的
  • 連続状態空間: そもそもテーブルが定義できない
  • 汎化能力がない: 似た状態間で知識が共有されない

解決策: 関数近似

パラメータ $\bm{\theta}$ を持つ関数 $Q(s, a; \bm{\theta})$ でQ値を近似します。ニューラルネットワークを用いれば、連続状態空間でもQ値を推定でき、似た状態間で自動的に汎化が起こります。

ナイーブな関数近似の問題

Q値テーブルの代わりにニューラルネットワークを直接使うと、以下の問題が生じます。

問題1: データの相関

エージェントが環境と逐次的にインタラクションすると、連続する経験 $(s_t, a_t, r_{t+1}, s_{t+1})$ は強く相関しています。教師あり学習ではデータが独立同分布(i.i.d.)であることを仮定しますが、この仮定が破られます。

相関のあるデータで学習すると、ネットワークが直近の経験に過適合し、過去の経験を忘れてしまいます。

問題2: 非定常なターゲット

Q学習のTD目標は

$$ y_t = r_{t+1} + \gamma \max_{a’} Q(s_{t+1}, a’; \bm{\theta}) $$

ターゲット $y_t$ 自身がパラメータ $\bm{\theta}$ に依存しています。$\bm{\theta}$ を更新するとターゲットも変化するため、最適化が不安定になります。いわば「動く的を撃つ」状態です。

DQNの安定化技法

DQNはこれらの問題を2つの技法で解決します。

Experience Replay

Experience Replayは、経験をバッファ(リプレイメモリ)$\mathcal{D}$ に蓄積し、学習時にランダムにサンプリングする手法です。

$$ \mathcal{D} = \{(s_t, a_t, r_{t+1}, s_{t+1}, \text{done}_t)\}_{t=1}^{N} $$

バッファサイズ $N$ は通常 $10^5 \sim 10^6$ 程度に設定します。

学習時にはミニバッチ $\mathcal{B} \subset \mathcal{D}$ をランダムにサンプリングし、これを使ってパラメータを更新します。

効果:

  1. 相関の除去: ランダムサンプリングにより、データ間の時間的相関が断ち切られる
  2. データ効率の向上: 1つの経験を複数回の学習に再利用できる
  3. 過去の経験の保持: 古い経験もバッファに残り、忘却を防ぐ

Target Network

Target Networkは、TD目標の計算に使うネットワーク(パラメータ $\bm{\theta}^{-}$)を、学習中のネットワーク(パラメータ $\bm{\theta}$)とは別に保持する手法です。

TD目標を

$$ y_t = r_{t+1} + \gamma \max_{a’} Q(s_{t+1}, a’; \bm{\theta}^{-}) $$

と計算します。$\bm{\theta}^{-}$ は一定期間($C$ ステップごと)に $\bm{\theta}$ をコピーして更新します。

$$ \bm{\theta}^{-} \leftarrow \bm{\theta} \quad (\text{every } C \text{ steps}) $$

効果:

ターゲットが一定期間固定されるため、最適化の「動く的」問題が緩和されます。

ソフト更新(Polyak averaging)を使う変種もあります。

$$ \bm{\theta}^{-} \leftarrow \tau \bm{\theta} + (1 – \tau) \bm{\theta}^{-} \quad (\tau \ll 1) $$

DQNの損失関数

ミニバッチ $\mathcal{B}$ に対する損失関数は

$$ \boxed{L(\bm{\theta}) = \frac{1}{|\mathcal{B}|} \sum_{(s, a, r, s’) \in \mathcal{B}} \left(y – Q(s, a; \bm{\theta})\right)^2} $$

ここで

$$ y = \begin{cases} r & \text{(エピソード終了)} \\ r + \gamma \max_{a’} Q(s’, a’; \bm{\theta}^{-}) & \text{(それ以外)} \end{cases} $$

$y$ は $\bm{\theta}$ の勾配計算においてターゲット(定数)として扱います。したがって勾配は

$$ \nabla_{\bm{\theta}} L(\bm{\theta}) = -\frac{2}{|\mathcal{B}|} \sum_{(s, a, r, s’) \in \mathcal{B}} \left(y – Q(s, a; \bm{\theta})\right) \nabla_{\bm{\theta}} Q(s, a; \bm{\theta}) $$

実装ではHuber損失(Smooth L1 Loss)を使うことも多いです。大きなTD誤差に対してロバストになります。

$$ L_{\text{Huber}}(\delta) = \begin{cases} \frac{1}{2}\delta^2 & \text{if } |\delta| \leq 1 \\ |\delta| – \frac{1}{2} & \text{otherwise} \end{cases} $$

DQNアルゴリズム

DQNの全体的な流れを整理します。

入力: 学習率 $\alpha$、割引率 $\gamma$、バッファサイズ $N$、ミニバッチサイズ $B$、ターゲット更新間隔 $C$、探索率 $\varepsilon$

  1. リプレイメモリ $\mathcal{D}$ を初期化(容量 $N$)
  2. Q-Network $Q(s, a; \bm{\theta})$ をランダムに初期化
  3. Target Network $Q(s, a; \bm{\theta}^{-})$ を $\bm{\theta}^{-} \leftarrow \bm{\theta}$ で初期化
  4. 各エピソードについて: – 初期状態 $s_0$ を取得 – 各ステップ $t$ について:
    • $\varepsilon$-greedy で行動 $a_t$ を選択
    • 行動を実行し、$r_{t+1}, s_{t+1}$ を観測
    • 経験 $(s_t, a_t, r_{t+1}, s_{t+1}, \text{done})$ を $\mathcal{D}$ に保存
    • $\mathcal{D}$ からミニバッチ $\mathcal{B}$ をサンプリング
    • TD目標 $y$ を計算(Target Networkを使用)
    • 損失 $L(\bm{\theta})$ を計算し、$\bm{\theta}$ を更新
    • $C$ ステップごとに $\bm{\theta}^{-} \leftarrow \bm{\theta}$

$\varepsilon$ の減衰

探索率 $\varepsilon$ は学習の進行に伴い減衰させます。

$$ \varepsilon_t = \max(\varepsilon_{\min}, \varepsilon_{\text{start}} – \frac{\varepsilon_{\text{start}} – \varepsilon_{\min}}{\varepsilon_{\text{decay}}} \cdot t) $$

初期は探索を重視し($\varepsilon \approx 1$)、学習が進むにつれて活用を重視します($\varepsilon \to \varepsilon_{\min}$)。

Pythonでの実装

CartPole環境でのDQN

import numpy as np
import matplotlib.pyplot as plt
from collections import deque
import random

# --- CartPole環境(OpenAI Gym不要) ---
class CartPoleEnv:
    """簡易CartPole環境"""

    def __init__(self):
        self.gravity = 9.8
        self.masscart = 1.0
        self.masspole = 0.1
        self.total_mass = self.masscart + self.masspole
        self.length = 0.5
        self.polemass_length = self.masspole * self.length
        self.force_mag = 10.0
        self.tau = 0.02
        self.theta_threshold = 12 * np.pi / 180
        self.x_threshold = 2.4
        self.max_steps = 500

    def reset(self):
        self.state = np.random.uniform(-0.05, 0.05, size=4)
        self.steps = 0
        return self.state.copy()

    def step(self, action):
        x, x_dot, theta, theta_dot = self.state
        force = self.force_mag if action == 1 else -self.force_mag

        costheta = np.cos(theta)
        sintheta = np.sin(theta)
        temp = (force + self.polemass_length * theta_dot**2 * sintheta) / self.total_mass
        thetaacc = (self.gravity * sintheta - costheta * temp) / (
            self.length * (4.0/3.0 - self.masspole * costheta**2 / self.total_mass))
        xacc = temp - self.polemass_length * thetaacc * costheta / self.total_mass

        x += self.tau * x_dot
        x_dot += self.tau * xacc
        theta += self.tau * theta_dot
        theta_dot += self.tau * thetaacc

        self.state = np.array([x, x_dot, theta, theta_dot])
        self.steps += 1

        done = (abs(x) > self.x_threshold or
                abs(theta) > self.theta_threshold or
                self.steps >= self.max_steps)
        reward = 1.0 if not done or self.steps >= self.max_steps else 0.0

        return self.state.copy(), reward, done


# --- ニューラルネットワーク(NumPyスクラッチ実装) ---
class SimpleNN:
    """2層全結合ネットワーク(ReLU活性化)"""

    def __init__(self, input_size, hidden_size, output_size, lr=0.001):
        # He初期化
        self.W1 = np.random.randn(input_size, hidden_size) * np.sqrt(2.0 / input_size)
        self.b1 = np.zeros(hidden_size)
        self.W2 = np.random.randn(hidden_size, hidden_size) * np.sqrt(2.0 / hidden_size)
        self.b2 = np.zeros(hidden_size)
        self.W3 = np.random.randn(hidden_size, output_size) * np.sqrt(2.0 / hidden_size)
        self.b3 = np.zeros(output_size)
        self.lr = lr

    def forward(self, x):
        """順伝播"""
        self.x = x
        self.z1 = x @ self.W1 + self.b1
        self.a1 = np.maximum(0, self.z1)  # ReLU
        self.z2 = self.a1 @ self.W2 + self.b2
        self.a2 = np.maximum(0, self.z2)  # ReLU
        self.z3 = self.a2 @ self.W3 + self.b3
        return self.z3

    def backward(self, dout):
        """逆伝播"""
        batch_size = self.x.shape[0]

        # 第3層
        dW3 = self.a2.T @ dout / batch_size
        db3 = np.mean(dout, axis=0)
        da2 = dout @ self.W3.T

        # ReLU
        da2 = da2 * (self.z2 > 0)

        # 第2層
        dW2 = self.a1.T @ da2 / batch_size
        db2 = np.mean(da2, axis=0)
        da1 = da2 @ self.W2.T

        # ReLU
        da1 = da1 * (self.z1 > 0)

        # 第1層
        dW1 = self.x.T @ da1 / batch_size
        db1 = np.mean(da1, axis=0)

        # パラメータ更新
        self.W3 -= self.lr * dW3
        self.b3 -= self.lr * db3
        self.W2 -= self.lr * dW2
        self.b2 -= self.lr * db2
        self.W1 -= self.lr * dW1
        self.b1 -= self.lr * db1

    def copy_from(self, other):
        """別のネットワークからパラメータをコピー"""
        self.W1 = other.W1.copy()
        self.b1 = other.b1.copy()
        self.W2 = other.W2.copy()
        self.b2 = other.b2.copy()
        self.W3 = other.W3.copy()
        self.b3 = other.b3.copy()


# --- Experience Replay ---
class ReplayBuffer:
    def __init__(self, capacity):
        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=float))

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


# --- DQN エージェント ---
class DQNAgent:
    def __init__(self, state_size=4, action_size=2, hidden_size=64,
                 lr=0.001, gamma=0.99, buffer_size=10000,
                 batch_size=64, target_update=100,
                 epsilon_start=1.0, epsilon_end=0.01, epsilon_decay=500):
        self.action_size = action_size
        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

        # Q-Network と Target Network
        self.q_network = SimpleNN(state_size, hidden_size, action_size, lr)
        self.target_network = SimpleNN(state_size, hidden_size, action_size, lr)
        self.target_network.copy_from(self.q_network)

        # Replay Buffer
        self.memory = ReplayBuffer(buffer_size)
        self.steps = 0

    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):
        """ε-greedy行動選択"""
        epsilon = self.get_epsilon()
        if np.random.random() < epsilon:
            return np.random.randint(self.action_size)
        else:
            q_values = self.q_network.forward(state.reshape(1, -1))
            return np.argmax(q_values[0])

    def train_step(self):
        """ミニバッチで1ステップ学習"""
        if len(self.memory) < self.batch_size:
            return 0.0

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

        # 現在のQ値
        q_values = self.q_network.forward(states)
        current_q = q_values[np.arange(self.batch_size), actions]

        # TD目標(Target Networkを使用)
        next_q_values = self.target_network.forward(next_states)
        max_next_q = np.max(next_q_values, axis=1)
        targets = rewards + self.gamma * max_next_q * (1 - dones)

        # 損失の勾配
        td_error = current_q - targets
        loss = np.mean(td_error ** 2)

        # 逆伝播用の勾配
        dout = np.zeros_like(q_values)
        dout[np.arange(self.batch_size), actions] = 2.0 * td_error
        self.q_network.backward(dout)

        # Target Networkの更新
        self.steps += 1
        if self.steps % self.target_update == 0:
            self.target_network.copy_from(self.q_network)

        return loss


# --- 学習の実行 ---
np.random.seed(42)
random.seed(42)

env = CartPoleEnv()
agent = DQNAgent(
    state_size=4, action_size=2, hidden_size=64,
    lr=0.001, gamma=0.99, buffer_size=10000,
    batch_size=32, target_update=100,
    epsilon_start=1.0, epsilon_end=0.01, epsilon_decay=300
)

n_episodes = 500
rewards_history = []
losses_history = []
epsilon_history = []

for episode in range(n_episodes):
    state = env.reset()
    total_reward = 0
    episode_loss = 0
    n_steps = 0

    while True:
        action = agent.select_action(state)
        next_state, reward, done = env.step(action)

        agent.memory.push(state, action, reward, next_state, done)
        loss = agent.train_step()

        total_reward += reward
        episode_loss += loss
        n_steps += 1
        state = next_state

        if done:
            break

    rewards_history.append(total_reward)
    losses_history.append(episode_loss / max(n_steps, 1))
    epsilon_history.append(agent.get_epsilon())

# --- 可視化 ---
fig, axes = plt.subplots(2, 2, figsize=(14, 10))

# (1) 学習曲線
window = 20
smooth_rewards = np.convolve(rewards_history, np.ones(window)/window, mode='valid')
axes[0, 0].plot(rewards_history, alpha=0.3, color='blue')
axes[0, 0].plot(np.arange(window-1, len(rewards_history)), smooth_rewards,
                'b-', linewidth=2, label=f'Moving avg (window={window})')
axes[0, 0].set_xlabel('Episode', fontsize=11)
axes[0, 0].set_ylabel('Total Reward', fontsize=11)
axes[0, 0].set_title('DQN Learning Curve (CartPole)', fontsize=13)
axes[0, 0].legend(fontsize=10)
axes[0, 0].grid(True, alpha=0.3)

# (2) 損失の推移
smooth_loss = np.convolve(losses_history, np.ones(window)/window, mode='valid')
axes[0, 1].plot(np.arange(window-1, len(losses_history)), smooth_loss,
                'r-', linewidth=1.5)
axes[0, 1].set_xlabel('Episode', fontsize=11)
axes[0, 1].set_ylabel('Average Loss', fontsize=11)
axes[0, 1].set_title('Training Loss', fontsize=13)
axes[0, 1].grid(True, alpha=0.3)

# (3) εの減衰
axes[1, 0].plot(epsilon_history, 'g-', linewidth=1.5)
axes[1, 0].set_xlabel('Episode', fontsize=11)
axes[1, 0].set_ylabel('Epsilon', fontsize=11)
axes[1, 0].set_title('Exploration Rate Decay', fontsize=13)
axes[1, 0].grid(True, alpha=0.3)

# (4) 報酬のヒストグラム(前半 vs 後半)
half = len(rewards_history) // 2
axes[1, 1].hist(rewards_history[:half], bins=20, alpha=0.5, color='red',
                label=f'First {half} episodes')
axes[1, 1].hist(rewards_history[half:], bins=20, alpha=0.5, color='blue',
                label=f'Last {half} episodes')
axes[1, 1].set_xlabel('Total Reward', fontsize=11)
axes[1, 1].set_ylabel('Count', fontsize=11)
axes[1, 1].set_title('Reward Distribution', fontsize=13)
axes[1, 1].legend(fontsize=10)
axes[1, 1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

print(f"最後の50エピソードの平均報酬: {np.mean(rewards_history[-50:]):.1f}")

Experience Replayによりデータの相関が除去され、Target Networkにより学習が安定化していることが、学習曲線の推移から確認できます。

DQNの拡張

DQNの基本形に対して、多くの改良が提案されています。

Double DQN

DQNでは $\max_{a’} Q(s’, a’; \bm{\theta}^{-})$ を計算する際、Q値の過大評価が起こりやすいです。Double DQNでは行動の選択と評価を分離します。

$$ y = r + \gamma Q\left(s’, \arg\max_{a’} Q(s’, a’; \bm{\theta}); \bm{\theta}^{-}\right) $$

行動は現在のQ-Networkで選び、評価はTarget Networkで行います。

Dueling DQN

Q値を状態価値 $V(s)$ とアドバンテージ $A(s, a)$ に分解します。

$$ Q(s, a; \bm{\theta}) = V(s; \bm{\theta}_V) + A(s, a; \bm{\theta}_A) – \frac{1}{|A|}\sum_{a’} A(s, a’; \bm{\theta}_A) $$

Prioritized Experience Replay

TD誤差が大きい経験を優先的にサンプリングします。

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

まとめ

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

  • 関数近似: Q値テーブルの代わりにニューラルネットワークで $Q(s, a; \bm{\theta})$ を近似
  • Experience Replay: 経験をバッファに蓄積しランダムサンプリングすることでデータの相関を除去
  • Target Network: TD目標の計算に別のネットワーク $\bm{\theta}^{-}$ を使い、学習を安定化
  • 損失関数: $L = \mathbb{E}[(y – Q(s,a;\bm{\theta}))^2]$(ターゲット $y$ は勾配を止める)
  • 拡張として Double DQN, Dueling DQN, Prioritized Experience Replay がある

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