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つの経験を複数回の学習に再利用できる
- 過去の経験の保持: 古い経験もバッファに残り、忘却を防ぐ
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$
- リプレイメモリ $\mathcal{D}$ を初期化(容量 $N$)
- Q-Network $Q(s, a; \bm{\theta})$ をランダムに初期化
- Target Network $Q(s, a; \bm{\theta}^{-})$ を $\bm{\theta}^{-} \leftarrow \bm{\theta}$ で初期化
- 各エピソードについて:
– 初期状態 $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 がある
次のステップとして、以下の記事も参考にしてください。