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

Deep Q-Network (DQN) とは?

DQNのコンセプト (画像はGeminiで作成)

Deep Q-Network (DQN)の概要

Deep Q-Network(DQN)は、Google DeepMindによって2013年に発表され(Playing Atari with Deep Reinforcement Learning)、 人間のプロプレイヤーを超えるレベルでAtari 2600のゲームをプレイすることを可能にした画期的な強化学習アルゴリズムです。 本記事では、DQNの基礎となるQ学習から説き起こし、DQNを構成する2つの中核技術「Experience Replay」と「Fixed Target Network」を解説します。さらに、PythonとPyTorchを用いたスクラッチ実装とその性能の可視化、そして実用的なStable Baselines3ライブラリによる実装を通じて、DQNの仕組みと効果を実践的に学びます。この記事は、より高度なPPOのような方策勾配法を学ぶ上での重要な基礎知識も提供します。

はじめに:Q学習とその限界

DQNを理解するためには、まずその基礎である Q学習(Q-Learning) を理解する必要があります。

Q学習の復習

Q学習は、ある状態 ss においてある行動 aa をとった場合の「将来にわたって得られる報酬の期待値(価値)」を学習するアルゴリズムです。この価値を Q値 Q(s,a)Q(s, a) と呼びます。

最もシンプルなQ学習では、このQ値をQテーブルという表形式で管理します。テーブルの行が「状態」、列が「行動」に対応し、各セルにその状態行動ペアのQ値が格納されます。エージェントは環境と相互作用しながら、以下の更新式(ベルマン方程式に基づきます)に従ってQテーブルの値を少しずつ更新し、最適な行動方針を学習していきます。

Q(s,a)Q(s,a)+α[r+γmaxaQ(s,a)Q(s,a)]Q(s, a) \leftarrow Q(s, a) + \alpha [r + \gamma \max_{a'} Q(s', a') - Q(s, a)]

ここで、

  • α\alpha は学習率
  • rr は報酬
  • γ\gamma は割引率
  • ss' は次の状態
  • maxaQ(s,a)\max_{a'} Q(s', a') は次の状態で取りうる最も価値の高い行動のQ値

を意味します。

事前知識

本記事はQ学習の基礎知識を前提としています。Qテーブルの更新プロセスや数式の詳細な意味に不安がある方は、先に Q学習(Q-Learning)とは? の記事をお読みいただくことをおすすめします。

Qテーブルの限界:次元の呪い

Qテーブルはシンプルで強力ですが、現実の多くの問題、特に状態空間が巨大な問題には対応できません。例えば、Atariのゲーム画面を状態として扱う場合を考えてみましょう。仮に84x84ピクセルの白黒画像だとしても、状態の数は 2(84×84)2^{(84 \times 84)} という天文学的な数になり、すべての状態を記録するテーブルを作成することは不可能です。これを 「次元の呪い」 と呼びます。

そこで、このQテーブルを関数で近似するというアイデアが生まれます。状態 ss を入力すると、各行動 aa に対するQ値を出力するような関数 Q(s,a;θ)Q(s, a; \theta) です。この関数近似器として、ディープニューラルネットワーク(DNN)を用いたものが Deep Q-Network (DQN) です。

DQNのコア技術

単純にQ学習のテーブルをニューラルネットワークに置き換えただけでは、学習はうまくいきません。DQNは、学習を安定させるための2つの重要なテクニックを導入しました。

1. Experience Replay(経験再生)

Experience Replay (画像はGeminiで作成)

強化学習では、エージェントが経験した一連のデータ(状態、行動、報酬、次状態のシーケンス)は時間的に強い相関を持っています。例えば、ゲームで右に進んでいる間は、似たような画面が連続します。このような相関の強いデータをそのまま学習に使うと、学習が不安定になり、最近の経験に過剰適合してしまいます。

そこでDQNは、エージェントの経験 (st,at,rt,st+1)(s_t, a_t, r_t, s_{t+1})リプレイバッファと呼ばれるメモリに一旦すべて保存します。そして、ネットワークを更新する際には、このバッファからランダムにミニバッチをサンプリングして学習に使用します。

これにより、

  • データの相関を断ち切る: 時間的に離れた多様な経験を混ぜることで、学習データがi.i.d.(独立同分布)に近くなり、学習が安定します。
  • データの再利用: 一度経験したデータを何度も学習に使えるため、データ効率が向上します。

2. Fixed Target Network(ターゲットネットワークの固定)

Fixed Target Network (画像はGeminiで作成)

Q学習の更新式を見てみると、更新したいQ値 Q(s,a)Q(s, a) と、更新目標(TDターゲット)である r+γmaxaQ(s,a)r + \gamma \max_{a'} Q(s', a') の両方に、同じQ関数が使われています。

TDターゲットとは?

DQNの損失関数でも使われる「TDターゲット」や「TD誤差」といった、Q学習の根本的な学習メカニズムについて復習したい方は、TD学習(時間的差分学習)とは?の記事をあわせてご覧ください。

これをニューラルネットワークで実装すると、更新対象のネットワーク(Policy Network)のパラメータ θ\theta が1ステップ更新されるたびに、TDターゲットを計算するためのネットワークも変化してしまいます。これは、「追いかけている目標が常に動き続ける」 ようなもので、学習を非常に不安定にします。

この問題を解決するため、DQNでは2つのネットワークを用意します。

  • Policy Network (Q(s,a;θ)Q(s, a; \theta)): 実際に行動を選択し、学習のたびに更新されるメインのネットワーク。
  • Target Network (Q(s,a;θ)Q(s, a; \theta^-)): TDターゲットを計算するためだけのネットワーク。こちらのパラメータ θ\theta^- は、普段は固定されており、一定間隔(例: 1000ステップごと)でPolicy Networkの最新の重み θ\theta がコピーされます。

これにより、TDターゲットが一定期間固定されるため、学習が安定します。損失関数は以下のようになります。

L(θ)=E(s,a,r,s)U(D)[(r+γmaxaQ(s,a;θ)TD Target (Target Networkで計算)Q(s,a;θ)Policy Networkで計算)2]L(\theta) = \mathbb{E}_{(s, a, r, s') \sim U(D)} \left[ \left( \underbrace{r + \gamma \max_{a'} Q(s', a'; \theta^-)}_{\text{TD Target (Target Networkで計算)}} - \underbrace{Q(s, a; \theta)}_{\text{Policy Networkで計算}} \right)^2 \right]

ここで DD はリプレイバッファを意味します。

DQNの実装(概念的な実装)

理論を理解したところで、実際にDQNをPyTorchで実装してみましょう。環境には、倒立振子のバランスをとるシンプルなタスクである CartPole-v1 を使用します。

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

以下のコードでは、DQNの学習と動作検証を行うために必要な環境およびライブラリをインストールします。強化学習の標準的な環境を提供する gymnasium、ディープニューラルネットワークの構築・最適化を担う torch、スコアの推移をプロットするための matplotlib、そして後に実践的な実装例として用いる実績豊富な強化学習ライブラリ stable-baselines3 を一括で導入します。

!pip install gymnasium torch matplotlib
!pip install stable-baselines3[extra]

このコマンドは、プロジェクトに必要なPythonライブラリをインストールします。gymnasium は強化学習の様々なシミュレーション環境を提供します。torch はニューラルネットワークの構築と学習に不可欠です。stable-baselines3 は、最適化済みの強化学習アルゴリズムを手軽に利用できるライブラリで、今回はスクラッチ実装との比較として、実用的なコードのシンプルさを示すために使用します。

QNetworkの定義

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

# GPU(CUDA)が利用可能であればそれを使用し、不可であればCPUを使用する設定を行います
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

# QNetwork: 状態を入力とし、各行動のQ値を出力するニューラルネットワークを定義します
# 記事内の Q(s, a; θ) に対応するメインネットワーク(Policy Network)と、
# Q(s, a; θ^-) に対応するターゲットネットワーク(Target Network)の両方でこのクラスがインスタンス化されます
class QNetwork(nn.Module):
def __init__(self, state_dim, action_dim):
super(QNetwork, self).__init__()
# 入力層から隠れ層1への全結合(状態の次元数から128次元へ変換)
self.fc1 = nn.Linear(state_dim, 128)
# 隠れ層1から隠れ層2への全結合
self.fc2 = nn.Linear(128, 128)
# 隠れ層2から出力層への全結合(行動の数だけの次元を出力し、それぞれのQ値を算出)
self.fc3 = nn.Linear(128, action_dim)

def forward(self, x):
# 活性化関数ReLUを適用して非線形性を導入します
x = torch.relu(self.fc1(x))
x = torch.relu(self.fc2(x))
# 出力層では活性化関数を通さず、生のQ値をそのまま返します
return self.fc3(x)
  • QNetwork: 3層のシンプルな全結合ニューラルネットワークです。CartPoleの状態(4次元ベクトル)を入力とし、2つの行動(左/右)それぞれのQ値を出力します。

QNetworkは、環境から得られる状態ベクトルを入力とし、その状態でエージェントが取りうる各行動の将来価値(Q値)を推定して出力する多層パーセプトロン(MLP)です。この記事の理論部分で解説したQ関数 Q(s,a;θ)Q(s, a; \theta) に直接対応しており、入力次元数にはCartPoleの状態次元(カートの位置、カートの速度、ポールの角度、ポールの角速度の4次元)、出力次元数には行動数(左に押す、右に押すの2次元)をそれぞれ指定します。活性化関数には非線形な特徴表現を学習させるために torch.relu を採用しており、出力層にはQ値そのものの範囲を制限しないよう、活性化関数を適用せず生の数値を出力します。このネットワークは後述のメインネットワーク(Policy Network)とターゲットネットワーク(Target Network)の両方で共通の構造として使用されます。

ReplayBufferの定義

以下のコードでは、DQNの学習を安定させるための中核技術である「Experience Replay(経験再生)」を処理するためのメモリバッファを実装します。エージェントが環境から得た一連の遷移データを一時的に保存し、そこからランダムに過去の経験をサンプリングしてニューラルネットワークに入力する仕組みを提供します。

# ReplayBuffer: エージェントの過去の経験を保存し、学習時にランダムにサンプリングしてデータの時間的相関を解消するクラス
class ReplayBuffer:
def __init__(self, capacity):
# Pythonの標準ライブラリdequeを使用し、最大容量(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_size分のデータをランダムに抽出し、リストとして返します
return random.sample(self.buffer, batch_size)

def __len__(self):
# 現在バッファに保存されているデータの個数を返します
return len(self.buffer)
  • ReplayBuffer: Pythonの collections.deque を使って、固定長のリングバッファを実装しています。maxlen を超えると古い経験から自動的に削除されます。

ReplayBufferクラスは、DQNの中核となる「Experience Replay」を制御するためのメモリ管理モジュールです。Python標準のダブルエンディッドキュー(collections.deque)を利用しており、上限容量 capacity を設定することで、上限を超えた新しいデータが追加された際に古いデータから順に自動削除される仕組みをシンプルに実現しています。

push メソッドによって、1ステップの遷移情報(現在の状態、行動、報酬、次の状態、エピソード終了判定)を1つのタプルとして蓄積します。そして学習のステップが走るたびに、sample メソッドによりバッファから指定したミニバッチサイズ(batch_size)のデータをランダムサンプリングします。これにより、強化学習において本質的な課題であった「時系列データの強い時間相関」を効果的に断ち切り、データを独立同分布(i.i.d.)に近づける役割を果たしています。

DQNAgentの定義

以下のコードでは、DQNアルゴリズムのエージェントとしての行動選択および学習のコアロジックをカプセル化した DQNAgent クラスを定義します。ここでは、行動を決定する policy_net と、時間差(TD)目標の計算の安定化を担う target_net を管理し、サンプリングした経験データに基づいてベルマン方程式から導出された損失関数に沿ってネットワーク重みの更新(学習)を行います。

# DQNAgent: DQN(Deep Q-Network)の行動選択、経験の蓄積、および重みの更新処理を取りまとめるエージェントクラス
class DQNAgent:
def __init__(self, state_dim, action_dim, replay_buffer_capacity=10000, batch_size=128, gamma=0.99, lr=1e-4):
# 状態空間の次元数(CartPoleでは4次元)
self.state_dim = state_dim
# 行動空間の数(CartPoleでは2通り: 左・右)
self.action_dim = action_dim
# 1回の学習で使用するミニバッチのサイズ
self.batch_size = batch_size
# 将来の報酬を割り引く割合(割引率 γ)
self.gamma = gamma

# メインネットワーク(Policy Network θ): 行動選択と頻繁な重み更新を行います
self.policy_net = QNetwork(state_dim, action_dim).to(device)
# ターゲットネットワーク(Target Network θ^-): TDターゲットの目標値算出のみに使用し、重みは一定期間固定します
self.target_net = QNetwork(state_dim, action_dim).to(device)
# 初期状態ではメインネットワークの重みをターゲットネットワークに完全に同期させます
self.target_net.load_state_dict(self.policy_net.state_dict())
# ターゲットネットワークは勾配計算やDropout、BatchNormの挙動を防ぐために評価モード(eval)にします
self.target_net.eval()

# メインネットワークを最適化するためのAdamオプティマイザを設定します
self.optimizer = optim.Adam(self.policy_net.parameters(), lr=lr)
# 経験データを一時保存するためのリプレイバッファを生成します
self.buffer = ReplayBuffer(replay_buffer_capacity)
# 誤差逆伝播の基準となる損失関数として、平均二乗誤差(MSE Loss)を定義します
self.loss_fn = nn.MSELoss()

def select_action(self, state, epsilon):
# ε-greedy法に基づき、探索と活用のバランスをとって行動を決定します
if random.random() < epsilon:
# 確率εでランダムに行動を選択します(探索: Exploration)
return random.randrange(self.action_dim)
with torch.no_grad():
# 確率 (1 - ε) で現在のQ値が最も高い行動を貪欲(Greedy)に選択します(活用: Exploitation)
# 入力状態をテンソルに変換し、バッチサイズ1の次元(unsqueeze(0))を追加してデバイスに転送します
state = torch.FloatTensor(state).unsqueeze(0).to(device)
# メインネットワークに状態を通し、各行動の予想Q値を出力します
q_values = self.policy_net(state)
# 最も高いQ値を持つ行動のインデックスを取得します
return q_values.max(1)[1].item()

def learn(self):
# バッファに十分なデータ(batch_size以上)が貯まるまでは学習を行いません
if len(self.buffer) < self.batch_size:
return

# リプレイバッファからランダムにbatch_size分の遷移データをサンプリングします
transitions = self.buffer.sample(self.batch_size)
# サンプリングされたデータの構造を[(s, a, r, s', done), ...]から、各要素ごとのリストにアンパックして再構成します
batch = list(zip(*transitions))

# PyTorchの計算効率を最大化するため、各データをテンソルに変換しGPU/CPUデバイスに送ります
state_batch = torch.FloatTensor(np.array(batch[0])).to(device)
action_batch = torch.LongTensor(batch[1]).unsqueeze(1).to(device)
reward_batch = torch.FloatTensor(batch[2]).to(device)
next_state_batch = torch.FloatTensor(np.array(batch[3])).to(device)
done_batch = torch.FloatTensor(batch[4]).to(device)

# 1. 現在のQ値 Q(s, a; θ) の算出
# メインネットワークに状態バッチを入力し、gatherを用いて実際に選択された行動(action_batch)に対応するQ値のみを抽出します
current_q_values = self.policy_net(state_batch).gather(1, action_batch)

# 2. ターゲットとなるQ値(TDターゲット)の算出
# ターゲットネットワークを用いた目標値計算では、メインネットワークの頻繁な更新による影響を防ぐため勾配計算を無効化します
with torch.no_grad():
# 次の状態における各行動の最大Q値 max_a' Q(s', a'; θ^-) をターゲットネットワークで計算します
next_q_values = self.target_net(next_state_batch).max(1)[0]
# ベルマン更新目標を算出: r + γ * (1 - done) * max_a' Q(s', a'; θ^-)
# エピソードが終了(doneがTrue=1)している場合は、遷移先が存在しないため将来価値を0とします
target_q_values = reward_batch + (1 - done_batch) * self.gamma * next_q_values

# 3. 損失 L(θ) の計算
# 予測Q値(current_q_values)と、目標Q値(target_q_values)の間の平均二乗誤差を算出します
loss = self.loss_fn(current_q_values.squeeze(), target_q_values)

# 4. バックプロパゲーションとパラメータ更新
# 勾配バッファをゼロクリアし、誤差逆伝播で勾配を計算後、メインネットワークのパラメータ θ を更新します
self.optimizer.zero_grad()
loss.backward()
self.optimizer.step()

def update_target_net(self):
# 定期的なタイミングで、メインネットワークの重み(θ)をターゲットネットワークの重み(θ^-)に完全にコピー(同期)します
self.target_net.load_state_dict(self.policy_net.state_dict())

DQNAgentクラスは、DQNの主要なデータ処理とモデル更新を管理する核心部分です。このクラスの実装内容は、本記事の上部で紹介した数式と完全に紐づいています。

1. 2つのネットワークの定義と役割(Fixed Target Network)

コンストラクタ init 内では、メインネットワークに対応する self.policy_net (Q(s,a;θ)Q(s, a; \theta)) と、目標値の算出のみを担当するターゲットネットワーク self.target_net (Q(s,a;θ)Q(s, a; \theta^-)) という、同じ構造を持つ2つのネットワークを定義しています。 これにより、重み更新対象のパラメータ θ\theta が頻繁に変化しても、TDターゲットの計算に用いるパラメータ θ\theta^- は一定のまま保持され、学習目標が絶えず揺れ動く「移動目標問題」を解決しています。一定期間が経過した後に、update_target_net メソッドによってメインネットワークの最新パラメータがターゲットネットワークへと同期されます。

2. 行動選択における確率的制御(ε-greedy法)

select_action では、探索と活用のトレードオフを制御します。

  • 確率 epsilon でランダムな行動インデックスを選択し、環境の未知の領域を開拓します(探索)。
  • 確率 1 - epsilon の場合、self.policy_net(state) を用いて予測Q値が最大となる行動を選択します(活用)。

3. ベルマン方程式と損失関数のPyTorchによる紐付け

最も重要なのが learn メソッドにおける一連の行列演算と損失計算です。これは前述の損失関数式 L(θ)=E[((r+γmaxaQ(s,a;θ))Q(s,a;θ))2]L(\theta) = \mathbb{E} \left[ \left( \left( r + \gamma \max_{a'} Q(s', a'; \theta^-) \right) - Q(s, a; \theta) \right)^2 \right] を忠実に実装したものです。

  • 予測Q値の計算: self.policy_net(state_batch) はバッチ内の各状態に対するすべての行動のQ値(形状: [batch_size, action_dim])を一度に計算します。ここから実際にエージェントがその時選択した行動に対応するQ値だけを抽出するために、gather(1, action_batch) を使用しています。これが数式における Q(s,a;θ)Q(s, a; \theta) に対応します。
  • TDターゲットの計算: 次状態における最も高いQ値 maxaQ(s,a;θ)\max_{a'} Q(s', a'; \theta^-) を計算するため、勾配追跡を無効化したコンテキスト(torch.no_grad())内で self.target_net(next_state_batch).max(1)[0] を実行しています。
  • ベルマン更新目標(TD Target)の完成: これに報酬を加算し、割引率を適用したものが target_q_values = reward_batch + (1 - done_batch) * self.gamma * next_q_values です。ここで、エピソード終了時(done_batch = 1)は将来の期待報酬が存在しないため、1done_batch1 - \text{done\_batch} をかけることで最大Q値の項をゼロにし、即時報酬のみになるよう補正しています。これが数式の r+γmaxaQ(s,a;θ)r + \gamma \max_{a'} Q(s', a'; \theta^-) に対応します。
  • 平均二乗誤差 (MSE) の適用: 得られた予測値とターゲットの差の二乗平均を self.loss_fn (MSELoss) により計算し、loss.backward() および self.optimizer.step() によって誤差逆伝播法を走らせ、メインネットワークのパラメータ θ\theta のみを最適化します。

訓練ループ

以下のコードでは、構築した DQNAgentCartPole-v1 環境を結合させ、実際にエージェントが相互作用しながらエピソードを繰り返す「訓練ループ」を実装します。ここでは、経験データの蓄積、ネットワークの更新、探索率 ϵ\epsilon の段階的な減衰、そしてターゲットネットワークへの定期的なパラメータ同期といった全体の学習サイクルを回します。

# --- ハイパーパラメータの設定 ---
EPISODES = 500 # 学習を実行する最大エピソード数
TARGET_UPDATE_FREQUENCY = 100 # メインネットワークからターゲットネットワークへ重みを同期する間隔(総ステップ数単位)
EPSILON_START = 1.0 # 学習初期における探索率 ε の初期値(100%ランダムに探索)
EPSILON_END = 0.01 # 探索率 ε の最小値(最低でも1%は探索を残す)
EPSILON_DECAY = 0.995 # エピソードごとに ε を減衰させる割合(徐々に活用を優先にするため)

# --- 環境とエージェントの初期化 ---
# GymnasiumよりCartPole-v1環境を作成します
env = gym.make('CartPole-v1')
# 環境の観測空間(状態)の次元数を取得(CartPoleは4次元)
state_dim = env.observation_space.shape[0]
# 環境の行動空間(エージェントが取れる行動の種類)の数を取得(CartPoleは2種類)
action_dim = env.action_space.n
# 事前に定義したDQNAgentインスタンスを作成
agent = DQNAgent(state_dim, action_dim)

# --- 訓練(学習)メインループ ---
scores = [] # 各エピソードの獲得スコアを記録するリスト
total_steps = 0 # 累積された全体の総ステップ数
epsilon = EPSILON_START # 現在の探索率を初期値にセット

print("--- DQN (スクラッチ) 学習開始 ---")
for episode in range(EPISODES):
# エピソードの開始時に環境をリセットし、初期状態を取得します
state, _ = env.reset()
episode_score = 0 # このエピソードにおける累積報酬(スコア)
done = False # エピソードが終了したかどうかのフラグ

while not done:
# 現在の状態と現在の探索率に基づいて行動を選択(ε-greedy法)
action = agent.select_action(state, epsilon)
# 選択した行動を実行し、次の状態、報酬、終了フラグ(terminated/truncated)などを取得
next_state, reward, terminated, truncated, _ = env.step(action)
# 通常終了、または時間切れ(最大ステップ到達など)のいずれかでエピソード終了と判定
done = terminated or truncated

# 経験(遷移データ)をエージェントのリプレイバッファに保存します
agent.buffer.push(state, action, reward, next_state, done)
# リプレイバッファ内のデータを用いてネットワークを1ステップ更新します
agent.learn()

# 状態を次のステップへ移行
state = next_state
# 報酬をスコアに加算(CartPoleでは1ステップ生存するごとに報酬 +1)
episode_score += reward
# 総ステップ数をカウントアップ
total_steps += 1

# 設定された更新ステップ間隔ごとに、ターゲットネットワークの重みを同期します
if total_steps % TARGET_UPDATE_FREQUENCY == 0:
agent.update_target_net()

# エピソード終了後、探索率 ε を少しずつ減衰させます(最小値以下には下げない)
epsilon = max(EPSILON_END, epsilon * EPSILON_DECAY)

# このエピソードのスコアを記録
scores.append(episode_score)
# 50エピソードごとに直近の平均スコアを出力し、学習の進捗を確認します
if (episode + 1) % 50 == 0:
print(f"エピソード {episode + 1}/{EPISODES}: 平均スコア (直近50) {np.mean(scores[-50:])}")

# スクラッチ実装による学習履歴を保存
scratch_dqn_scores = scores
print("--- DQN (スクラッチ) 学習完了 ---")

このメインループは、エージェントが実際に環境内で試行錯誤を行いながら自己改善を繰り返す強化学習のダイナミクスを制御しています。

  • εの指数減衰: 学習の進捗に伴って epsilon に減衰係数 EPSILON_DECAY (0.995) を掛け合わせることで、初期の広範な「探索」モードから、学習したQ値を信頼して最適な意思決定を行う「活用」モードへと滑らかにシフトさせています。
  • 1ステップごとの継続的な学習: エージェントが1アクション取る(ステップが進む)たびに agent.learn() が呼び出されます。バッファにミニバッチサイズ以上のデータが蓄積されている限り、毎ステップごとに勾配降下法が実行され、モデルパラメータが最適化されていきます。
  • 同期周波数の制御: 変数 total_stepsTARGET_UPDATE_FREQUENCY (100ステップ)に達するごとに、ターゲットネットワークが最新の重みに同期されます。これにより、緩やかなペースで目標値が再キャリブレーションされ、安定した学習を保証しています。

実行結果

--- DQN (スクラッチ) 学習開始 ---
エピソード 50/500: 平均スコア (直近50) 20.48
エピソード 100/500: 平均スコア (直近50) 47.38
エピソード 150/500: 平均スコア (直近50) 117.38
エピソード 200/500: 平均スコア (直近50) 188.26
エピソード 250/500: 平均スコア (直近50) 257.06
エピソード 300/500: 平均スコア (直近50) 265.8
エピソード 350/500: 平均スコア (直近50) 338.42
エピソード 400/500: 平均スコア (直近50) 454.02
エピソード 450/500: 平均スコア (直近50) 401.18
エピソード 500/500: 平均スコア (直近50) 434.04
--- DQN (スクラッチ) 学習完了 ---

上記のコードを実行すると、学習の進捗に応じて50エピソードごとに平均スコアが出力されます。学習が進むにつれて、エージェントはより長くポールを立て続けることができるようになり、平均スコアが徐々に上昇していく様子が確認できます。

性能比較と可視化

スクラッチで実装したDQNと、比較対象として完全ランダムに行動した場合のスコアを比較してみましょう。

以下のコードでは、スクラッチで開発したDQNエージェント、学習を一切行わず完全ランダムに行動するベンチマーク用のエージェントという2種類の実行結果を統合します。収集したエピソードごとのスコアに対して移動平均を算出して平滑化し、matplotlib を用いて学習曲線の比較グラフを出力します。

# ==========================================
# ランダム行動エージェントのスコアを収集
# ==========================================
random_scores = []
env_random = gym.make('CartPole-v1')
print("--- ランダムエージェント 評価開始 ---")
for episode in range(EPISODES):
env_random.reset()
done = False
episode_score = 0
while not done:
action = env_random.action_space.sample()
_, reward, terminated, truncated, _ = env_random.step(action)
done = terminated or truncated
episode_score += reward
random_scores.append(episode_score)
print("--- ランダムエージェント 評価完了 ---")

# ==========================================
# グラフ描画(学習曲線の比較)
# ==========================================
def moving_average(data, window_size):
if len(data) < window_size:
return np.mean(data)
return np.convolve(data, np.ones(window_size), 'valid') / window_size

plt.figure(figsize=(12, 7))
window = 50

# スクラッチ実装(学習中)
plt.plot(moving_average(scratch_dqn_scores, window), label=f'DQN (Scratch) - MA({window})', color='blue')

# ランダム行動
plt.plot(moving_average(random_scores, window), label=f'Random - MA({window})', color='orange', linestyle='--')

plt.title('Training Learning Curve: Scratch vs Random')
plt.xlabel(f'Episodes (Moving Average Window = {window})')
plt.ylabel('Score')
plt.legend()
plt.grid(True)
plt.show()

スクラッチDQNとランダムの比較グラフ (画像はGeminiで作成)

このプロットスクリプトは、異なる制御アルゴリズムの性能差を視覚的に明らかにするためのものです。 生のスコア(累積報酬)は毎エピソードのゲーム展開によって激しい振れ幅(ノイズ)を持ちます。そのため、moving_average 関数の中で np.convolve (畳み込み関数)を使用して、直近50エピソードのスコアの算術平均(移動平均)に平滑化しています。これにより、エージェントがステップを踏むごとに着実にスコアを伸ばしているか(=学習の進捗傾向)を容易に分析できるようになります。 グラフ上では、全く学習を行わない Random(平均約10〜20点付近に低迷)に対し、スクラッチ実装のDQNがエピソードを経るごとに大きくスコアを伸ばし、CartPole-v1の目標閾値である高いスコアまで到達する様子が描画され、価値関数近似とニューラルネットワークの有効性が一目で理解できるよう設計されています。

Stable Baselines3によるDQN実装

スクラッチ実装はアルゴリズムの理解に役立ちますが、実用上は Stable Baselines3 のような最適化されたライブラリを使うのが一般的です。同じタスクをSB3で解くコードは非常にシンプルになります。

以下のコードでは、実用的な開発現場で広く普及している強化学習フレームワークである Stable Baselines3 (SB3) を使用して、同じ CartPole-v1 環境のDQNモデルを構築・学習・評価します。スクラッチ実装で記述した数百行に及ぶアルゴリズム設定やループが、SB3によってどのように数行に抽象化されるのかを確認し、学習完了後の評価ループを回して最終スコアを取得します。

# Gymnasium(環境)とStable Baselines3(DQN)のインポート
import gymnasium as gym
from stable_baselines3 import DQN
from stable_baselines3.common.evaluation import evaluate_policy
from stable_baselines3.common.monitor import Monitor
import matplotlib.pyplot as plt

# CartPole環境を初期化
env_sb3 = gym.make('CartPole-v1')
# Monitorラッパーを使ってエピソードごとの報酬などを自動記録
env_sb3 = Monitor(env_sb3)

# DQNモデルの定義
# MlpPolicy: 多層パーセプトロン(全結合NN)を使用
model_sb3_dqn = DQN(
'MlpPolicy',
env_sb3,
learning_rate=1e-3, # 学習率
buffer_size=20000, # Experience Replayのバッファサイズ
learning_starts=500, # 学習を開始するまでのランダム行動ステップ数
batch_size=64, # 1回の学習に使うミニバッチサイズ
gamma=0.99, # 割引率
train_freq=1, # ネットワークの更新頻度(1ステップごと)
gradient_steps=1, # 1回の更新で行う勾配ステップ数
target_update_interval=500, # Target Networkを更新する間隔
exploration_fraction=0.5, # 探索率(ε)を減衰させる期間の割合
exploration_final_eps=0.01, # 探索率(ε)の最小値
verbose=0 # ログの出力レベル(0は出力なし)
)

print("--- DQN (Stable Baselines3) 学習開始 ---")
# 指定した総ステップ数だけ学習を実行
model_sb3_dqn.learn(total_timesteps=150_000)
print("--- DQN (Stable Baselines3) 学習完了 ---")

# 学習中のエピソードごとのスコア推移をプロット
plt.plot(env_sb3.get_episode_rewards())
plt.title('DQN (Stable Baselines3) Learning Curve')
plt.xlabel('Episodes')
plt.ylabel('Score')
plt.show()

コードの解説

SB3を使用すると、DQN特有の複雑な処理(ネットワーク定義、経験のバッファへの保存、ミニバッチの抽出、ターゲットネットワークの同期など)がすべて DQN クラスの内部に隠蔽されます。

  • Monitor: 環境をラップするだけで、毎エピソードの合計報酬や長さを自動的に追跡・記録してくれます。
  • DQN のハイパーパラメータ: buffer_size(リプレイバッファの容量)や target_update_interval(Fixed Target Networkの更新頻度)、exploration_fraction(ε-greedyの減衰割合)など、本記事で解説したDQNの重要概念がすべて引数として簡単に設定・調整できるようになっています。
  • learn(): 目的の総タイムステップ数を渡すだけで学習ループが完結します。

実行結果

--- DQN (Stable Baselines3) 学習開始 ---
--- DQN (Stable Baselines3) 学習完了 ---

DQN (Stable Baselines3) スコア推移

グラフからわかるように、初期は低いスコアで推移しますが、学習が進むにつれてスコアが着実に上昇し、最終的にはCartPole-v1の最高スコアである500点に安定して到達しています。Stable Baselines3を使用することで、スクラッチ実装と比較してコード量を劇的に削減できるだけでなく、高度に最適化されたハイパーパラメータや内部の安定化処理をすぐに活用できることがわかります。

PPOへの橋渡し:価値ベース手法の先へ

本記事で学んだDQNは価値ベース(Value-based) の手法と呼ばれます。これは、行動の「価値」を最大化するように学習を進めるアプローチです。DQNはこのアプローチで大きな成功を収めましたが、いくつかの限界も持っています。

  • 離散行動空間: DQNは、各行動のQ値を比較して最大のものを選択するため、行動が「左」「右」「ジャンプ」のように離散的(有限個)である必要があります。車のハンドルのように連続的な角度を扱う問題には直接適用できません。
  • 決定論的な方策: 最適な行動が常に一つに定まるため、確率的な行動(例えば、じゃんけんでグーを出す確率70%、チョキを30%など)を学習するのが困難です。

これらの課題を克服するのが、PPO(Proximal Policy Optimization)に代表される方策ベース(Policy-based) の手法です。方策ベースの手法は、価値を介さず、ある状態でどのような行動をとるかの方策(Policy) そのものを直接学習します。これにより、連続値の行動を扱ったり、確率的な方策を学習したりすることが可能になります。

PPOは Actor-Critic という、方策(Actor)と価値(Critic)の両方を学習するアーキテクチャを採用しています。本記事で学んだ「ニューラルネットワークによる価値関数の近似」の考え方は、まさにPPOにおける Critic 部分の基礎となっており、DQNの理解はPPOをより深く学ぶための重要なステップとなります。

まとめ

本記事では、強化学習の歴史における重要なマイルストーンであるDeep Q-Network (DQN)について、その理論的背景から実践的な実装までを解説しました。Q学習のテーブルベースの手法が抱える「次元の呪い」をニューラルネットワークによる関数近似で克服し、さらに「Experience Replay」と「Fixed Target Network」という2つの独創的なアイデアによって学習を安定化させるDQNの仕組みを学びました。

スクラッチ実装とその性能の可視化、さらにStable Baselines3による実装の紹介を通じて、アルゴリズムの内部構造と、ライブラリが提供する抽象化の利便性の両方を体感しました。最終的に、DQNがランダムな行動をはるかに上回る性能を達成できることを視覚的に確認しました。

DQNは価値ベース手法の代表格であり、そのコンセプトはPPOのようなより高度なActor-Critic手法を理解するための基盤となります。