NumPyだけで作る強化学習エージェント:Q学習を「コードから逆引き」で完全理解する実装ハンズオン

約44分で読めます by ぽんたぬき
NumPyだけで作る強化学習エージェント:Q学習を「コードから逆引き」で完全理解する実装ハンズオン

NumPyだけで作る強化学習エージェント:Q学習を「コードから逆引き」で完全理解する実装ハンズオン


はじめに:なぜ2026年に「NumPyだけ」でQ学習を学ぶのか

フレームワークが隠してしまう「強化学習の本質」

PyTorchやTensorFlowが成熟した現在、強化学習(Reinforcement Learning / RL)の実装は驚くほど簡単になりました。stable-baselines3 のわずか数行でPPOエージェントが動き、RLlib を使えばスケーラブルな分散学習も実現できます。

しかし、この「便利さ」には代償があります。フレームワークのAPIが抽象化の層を何枚も重ねる結果、「エージェントが実際に何を学んでいるのか」「Q値とはどんな数字なのか」「ベルマン方程式のどの部分がコードの何行目に対応するのか」——こうした本質的な問いへの答えが見えなくなってしまうのです。

近年、この問題意識を背景に「教育目的のスクラッチ実装」が再評価されています。その代表例が CleanRL です。CleanRL は「1ファイル、依存最小、アルゴリズムの本質だけ」をコンセプトに設計された研究・教育用コードベースで、2024〜2025年にかけて引用数・スター数ともに急増しました。「フレームワークに頼らず実装できること」が、深い理解の証明として再び評価される時代になっています。

加えて、説明可能AI(XAI) の観点からもNumPy実装の価値が高まっています。医療・金融・自律走行などの規制産業では、「なぜそのアクションを選んだのか」をQ値として可視化・追跡できるテーブル型Q学習の透明性が、ブラックボックスのニューラルネットより好まれるケースが増えています。

Q学習がLLM時代に再び重要になった理由

2025〜2026年にかけて、Q学習が「古典的な手法」から「LLMを理解するための必須素養」へと再定義されています。

その核心は RLHF(Reinforcement Learning from Human Feedback)GRPO(Group Relative Policy Optimization) です。ChatGPTをはじめとする大規模言語モデルのファインチューニングは、強化学習を基盤としています。報酬モデル・方策・価値関数——これらの概念はQ学習の語彙と完全に対応しています。

「RLHFを理解したければ、まずQテーブルを手で動かせ」——これは誇張ではなく、実際に研究者や上級エンジニアが口をそろえて言うアドバイスです。抽象的な価値関数の概念を、NumPyの行列として具体的に操作した経験が、後のRLHF理解を決定的に加速させます。

この記事で身につくこと・前提知識

この記事で身につくこと:

  • Q学習の5つのコアコンセプトの完全理解
  • NumPyだけで動く GridWorld 環境とQ学習エージェントの実装能力
  • ベルマン方程式とPythonコードの1行1行の対応関係の把握
  • ハイパーパラメータが学習に与える影響の実験的理解
  • DQN・RLHF への発展的な学習の足がかり

前提知識:

  • Python の基本的な文法(関数・クラス・ループ)
  • NumPy の基本操作(np.zeros, np.argmax, np.max 程度)
  • 強化学習の事前知識は不要です

第1章:Q学習の核心概念を5つのキーワードで押さえる

実装に入る前に、Q学習を構成する5つのキーワードを整理します。これらは後ほどコードの各行と1対1で対応させて解説するため、まず「概念の輪郭」だけを掴んでおいてください。

Qテーブル(Q-Table)とは何か

Qテーブルとは、「ある状態で、ある行動をとることの価値(期待収益)」を格納した2次元の行列です。

import numpy as np

q_table = np.zeros((n_states, n_actions))
# 行: 状態(state)のインデックス
# 列: 行動(action)のインデックス
# 値: Q値(その状態・行動の組み合わせの「良さ」)

たとえば q_table[5, 2] は「状態5において行動2をとった場合の価値」を意味します。初期値をすべて0にする理由は、「エージェントが何も知らない状態からスタートする」という前提を素直に表現するためです(楽観的初期値との比較は第3章で扱います)。

ベルマン方程式:Q値が更新される唯一のルール

Q学習の核心はこの方程式です:

Q(s, a) ← Q(s, a) + α [ r + γ · max_a' Q(s', a') − Q(s, a) ]

直感的に言うと:「今の推定値(左辺)を、より良い推定値(カッコ内)に少しずつ近づける」という操作です。

カッコ内の r + γ · max Q(s', a') − Q(s, a)TD誤差(Temporal Difference Error) と呼ばれ、「現在の推定がどれだけ間違っていたか」を表します。このTD誤差に学習率 α をかけた量だけQ値を修正します。

ε-greedy戦略:探索と活用のバランス

エージェントは毎ステップ「次にどの行動をとるか」を決める必要があります。ε-greedy戦略では:

  • 確率ε:ランダムな行動(探索 / Exploration)
  • 確率(1−ε):Q値が最大の行動(活用 / Exploitation)

εは学習開始時は1.0(完全ランダム)から始め、エピソードが進むにつれて0.01まで減衰させます。「最初はいろいろ試し、徐々に学んだことを信じる」という学習の自然な流れを模倣しています。

学習率α:記憶の更新速度

αは「Q値をどれだけ大胆に更新するか」を制御します。

  • α = 0:一切学ばない(Q値が変化しない)
  • α = 1:直近の経験しか覚えない(過去を全て忘れる)
  • α = 0.1(推奨初期値):安定した学習と収束のバランスが良い

αが大きすぎるとQ値が振動・発散し、小さすぎると収束が遅くなります。実践的には 0.01〜0.5 の範囲で始め、学習曲線を見ながら調整します。

割引率γ:近視眼 vs 遠視眼

γは「将来の報酬をどれだけ重視するか」を決めます。

  • γ = 0:今の報酬だけを最大化(完全近視眼)
  • γ = 1:未来の報酬も現在と同等に重視(完全遠視眼)
  • γ = 0.99(推奨):遠い将来の報酬もほぼ等価に扱いつつ、収束を保証

エピソード型タスク(ゴールに到達したら終了するタイプ)ではγ = 0.99前後が定番です。γが小さいと近道でも遠回りでもエージェントが気にしなくなり、最適経路が学習されにくくなります。


第2章:環境を作る──GridWorldをNumPyで実装する

GridWorldとは:なぜ最初の環境に最適か

GridWorld(迷路問題)は、Q学習入門に最適な環境です。その理由は3つあります:

  1. 状態空間が有限・離散:Qテーブルが素直に使える
  2. 完全観測可能:エージェントは自分の位置を常に把握できる
  3. 可視化が容易:学習結果をグリッドの矢印マップで直感的に確認できる

今回実装するのは 4×4グリッド(16状態)です。仕様は以下の通りです:

  • スタート:左上(0, 0)
  • ゴール:右下(3, 3)、報酬 +1
  • 壁:(1, 1)と(2, 2)に配置、衝突すると報酬 -1
  • 通常移動:報酬 -0.01(小さな時間ペナルティ)

NumPyで迷路環境を実装する

import numpy as np

class GridWorld:
    """4×4 GridWorld 環境"""
    
    def __init__(self):
        self.n_rows = 4
        self.n_cols = 4
        self.n_states = self.n_rows * self.n_cols  # 16
        self.n_actions = 4  # 0:上, 1:下, 2:左, 3:右
        
        # グリッドの定義(0:通路, 1:壁, 2:ゴール)
        self.grid = np.array([
            [0, 0, 0, 0],
            [0, 1, 0, 0],  # (1,1) が壁
            [0, 0, 1, 0],  # (2,2) が壁
            [0, 0, 0, 2],  # (3,3) がゴール
        ])
        
        self.start_state = 0   # (0,0) → インデックス 0
        self.goal_state  = 15  # (3,3) → インデックス 15
        self.state = self.start_state
    
    def reset(self):
        """環境をリセットしてスタート状態を返す"""
        self.state = self.start_state
        return self.state
    
    def _index_to_pos(self, index):
        """1Dインデックス → (row, col) に変換"""
        return index // self.n_cols, index % self.n_cols
    
    def _pos_to_index(self, row, col):
        """(row, col) → 1Dインデックスに変換"""
        return row * self.n_cols + col
    
    def step(self, action):
        """
        行動を受け取り (next_state, reward, done) を返す
        action: 0=上, 1=下, 2=左, 3=右
        """
        row, col = self._index_to_pos(self.state)
        
        # 移動先の計算
        moves = [(-1, 0), (1, 0), (0, -1), (0, 1)]
        d_row, d_col = moves[action]
        new_row = row + d_row
        new_col = col + d_col
        
        # 壁・境界チェック:グリッド外または壁ならその場に留まり罰則
        if (new_row < 0 or new_row >= self.n_rows or
            new_col < 0 or new_col >= self.n_cols or
            self.grid[new_row, new_col] == 1):
            return self.state, -1.0, False  # 罰則、移動なし
        
        # 状態を更新
        self.state = self._pos_to_index(new_row, new_col)
        
        # 報酬とゴール判定
        if self.grid[new_row, new_col] == 2:  # ゴール
            return self.state, 1.0, True
        else:
            return self.state, -0.01, False   # 時間ペナルティ

報酬設計のポイント

報酬設計(リワードシェーピング)は強化学習において最も影響が大きい設計判断の一つです。

今回の設計では「小さな時間ペナルティ(-0.01)」を採用しています。これにより、エージェントは「ゴールに到達するだけでなく、なるべく早く到達する」ことを学びます。ペナルティがない(ゴール報酬だけ)の場合、エージェントはゴールに向かう動機はあっても、遠回りを避ける動機がありません。

スパース報酬(ゴールだけに+1)と密な報酬(各ステップにペナルティ付き)を比較すると、密な報酬の方が一般的に収束が速くなります。ただし中間報酬の設計が不適切だと、意図しない行動をエージェントが学んでしまう「報酬ハッキング」の原因にもなります。


第3章:エージェントを実装する──コード全体を先に示し、逆引きで解剖する

【コード全体】まず動かしてみる(コピペで即実行)

以下は GridWorld 環境上でQ学習エージェントを訓練する完全なコードです。まずそのまま実行してみてください。

import numpy as np

# =====================
# 設定(ハイパーパラメータ)
# =====================
N_STATES      = 16      # 4×4 GridWorld の状態数
N_ACTIONS     = 4       # 上・下・左・右
ALPHA         = 0.1     # 学習率
GAMMA         = 0.99    # 割引率
EPSILON       = 1.0     # 初期探索率
EPSILON_MIN   = 0.01    # 探索率の最小値
EPSILON_DECAY = 0.995   # 探索率の減衰係数
EPISODES      = 5000    # 総エピソード数

# =====================
# 初期化
# =====================
env     = GridWorld()
q_table = np.zeros((N_STATES, N_ACTIONS))
rewards_history = []  # 各エピソードの総報酬を記録

# =====================
# メイン学習ループ
# =====================
for episode in range(EPISODES):
    state = env.reset()
    total_reward = 0.0
    done = False

    while not done:
        # ---- ε-greedy による行動選択 ----
        if np.random.random() < EPSILON:
            action = np.random.randint(N_ACTIONS)  # 探索:ランダム
        else:
            action = np.argmax(q_table[state])      # 活用:最良の行動

        # ---- 環境とのインタラクション ----
        next_state, reward, done = env.step(action)
        total_reward += reward

        # ---- Q値の更新(ベルマン方程式) ----
        q_table[state, action] += ALPHA * (
            reward + GAMMA * np.max(q_table[next_state]) - q_table[state, action]
        )

        state = next_state

    # ---- エピソード終了後:εを減衰させる ----
    EPSILON = max(EPSILON_MIN, EPSILON * EPSILON_DECAY)
    rewards_history.append(total_reward)

    # ---- 500エピソードごとに進捗を表示 ----
    if (episode + 1) % 500 == 0:
        avg_reward = np.mean(rewards_history[-500:])
        print(f"Episode {episode + 1:5d} | "
              f"Avg Reward: {avg_reward:6.3f} | "
              f"ε: {EPSILON:.4f}")

print("\n学習完了!")

実行結果の例:

Episode   500 | Avg Reward: -0.847 | ε: 0.0820
Episode  1000 | Avg Reward: -0.312 | ε: 0.0067
Episode  1500 | Avg Reward:  0.721 | ε: 0.0100
Episode  5000 | Avg Reward:  0.847 | ε: 0.0100
学習完了!

エピソードが進むにつれて平均報酬が上昇し、エージェントが「ゴールへの到達」を学習していることが確認できます。


【逆引き①】Qテーブル初期化:np.zeros がゼロである理由

q_table = np.zeros((N_STATES, N_ACTIONS))

初期値をゼロにするのは「エージェントが何の事前知識も持たない」という前提を素直に表現しています。しかし、これが唯一の選択肢ではありません。

楽観的初期値(np.ones)との比較:

# 楽観的初期値:すべてのQ値を高い値で初期化
q_table_optimistic = np.ones((N_STATES, N_ACTIONS))

楽観的初期値を使うと、エージェントは「まだ試していない行動の方が良いはず」と常に感じるため、探索が促進されます。特に ε を固定(減衰なし)する場合に有効ですが、誤った期待値が長く残ると収束が遅くなる欠点があります。

ゼロ初期化は収束の安定性が高く、ε-greedy による探索と組み合わせた場合に最もバランスが良いため、Q学習のデフォルトとして広く採用されています。


【逆引き②】行動選択ブロック:np.random.random()np.argmax() の役割

if np.random.random() < EPSILON:
    action = np.random.randint(N_ACTIONS)  # 探索
else:
    action = np.argmax(q_table[state])      # 活用

np.random.random() は [0.0, 1.0) の一様乱数を返します。これがεより小さければ探索、大きければ活用という判断を、わずか1行で実現しています。

np.argmax(q_table[state]) は、状態 state における全行動のQ値の中で最大値を持つインデックスを返します。たとえば:

q_table[5] = [0.12, 0.87, 0.34, 0.56]
# np.argmax → 1(インデックス1、つまり「下」が最善)

タイブレーク問題: 学習初期はすべてのQ値がゼロのため、np.argmax は常にインデックス0(通常は「上」)を返します。これにより特定の行動に偏りが生じます。解決策として、初期は ε = 1.0 の完全ランダムから始めることで、この問題を実用上は回避しています。


【逆引き③】Q値更新の1行:ベルマン方程式との完全対応

q_table[state, action] += ALPHA * (
    reward + GAMMA * np.max(q_table[next_state]) - q_table[state, action]
)

この1行がQ学習の全てです。ベルマン方程式と完全に対応させると:

数式の項 コードの対応 意味
Q(s, a) q_table[state, action] 現在の推定Q値
r reward 実際に受け取った報酬
γ · max Q(s', a') GAMMA * np.max(q_table[next_state]) 次状態の最大Q値(割引済み)
α ALPHA 学習率
[...] 全体 (...) TD誤差(Temporal Difference Error)

reward + GAMMA * np.max(q_table[next_state]) が「あるべきQ値(ターゲット)」、q_table[state, action] が「現在のQ値(推定)」です。その差(TD誤差)に ALPHA をかけた量だけ、現在の推定値を正しい方向に更新します。

重要な設計原則: np.max(q_table[next_state]) は「次状態で最善の行動をとった場合」のQ値を使います。実際に次状態でその行動をとるかどうかに関わらず、常に最大値を使うのがQ学習(off-policy)の特徴です。これが SARSA(on-policy)との本質的な違いです。


【逆引き④】εの減衰:max(EPSILON_MIN, EPSILON * EPSILON_DECAY)

EPSILON = max(EPSILON_MIN, EPSILON * EPSILON_DECAY)

この1行で「指数減衰」が実現されます。EPSILON_DECAY = 0.995 の場合:

  • エピソード 1:ε = 1.000
  • エピソード 100:ε ≈ 0.606
  • エピソード 500:ε ≈ 0.082
  • エピソード 1000:ε ≈ 0.007(≒ EPSILON_MIN = 0.01 に到達)

max(EPSILON_MIN, ...) により、εは最小値(0.01)以下にはなりません。完全に探索をゼロにしないことで、学習済みの後も低確率で新しい経験を積み続け、環境変化への適応能力を維持します。


第4章:学習結果を可視化する──Q値の「賢さ」を目で見る

学習曲線:NumPyだけで移動平均を計算する

matplotlib を使わずに、NumPy だけで学習の収束を確認できます。

# 直近100エピソードの移動平均報酬を計算
window = 100
moving_avg = np.convolve(
    rewards_history,
    np.ones(window) / window,
    mode='valid'
)

# テキストでの簡易プロット
print("=== 学習曲線(移動平均報酬) ===")
checkpoints = np.linspace(0, len(moving_avg) - 1, 10, dtype=int)
for i in checkpoints:
    bar_len = int((moving_avg[i] + 1.0) * 20)  # -1〜+1 を 0〜40 にスケール
    bar = "█" * max(0, bar_len)
    print(f"Ep {i + window:5d}: {moving_avg[i]:+.3f} |{bar}")

学習が「収束した」と判断する目安は、直近100エピソードの平均報酬が安定して正の値(今回の環境では 0.7〜0.9 程度)になることです。

Qテーブルのヒートマップ:各状態で何を学んだか

# 各状態での最大Q値をグリッド形状に変換
max_q_grid = q_table.max(axis=1).reshape(4, 4)

print("=== 最大Q値ヒートマップ ===")
print("(値が高いほど、その位置から早くゴールに到達できることを示す)\n")
for row in range(4):
    for col in range(4):
        print(f"{max_q_grid[row, col]:6.3f}", end=" ")
    print()

実行結果例:

=== 最大Q値ヒートマップ ===
 0.904  0.913  0.928  0.941
 0.895  0.000  0.940  0.952
 0.908  0.920  0.000  0.963
 0.920  0.941  0.963  0.000

壁の位置(Q値=0)と、ゴールに近いほど高いQ値のグラデーションが確認できます。これがエージェントの「学習済み知識の地図」です。

最適方策の矢印マップ

# 各状態でのベスト行動を矢印で表示
ARROWS = ['↑', '↓', '←', '→']
best_actions = np.argmax(q_table, axis=1)

print("=== 最適方策マップ ===")
for row in range(4):
    for col in range(4):
        state_idx = row * 4 + col
        if (row, col) == (1, 1) or (row, col) == (2, 2):
            print(" ■ ", end="")  # 壁
        elif (row, col) == (3, 3):
            print(" ★ ", end="")  # ゴール
        else:
            print(f" {ARROWS[best_actions[state_idx]]} ", end="")
    print()

実行結果例:

=== 最適方策マップ ===
 → →  →  ↓
 ↓  ■  ↓  ↓
 →  →  ■  ↓
 →  →  →  ★

エージェントが「壁を避けながらゴールへ向かう最短経路」を学習していることが、矢印マップから直感的に確認できます。


第5章:ハイパーパラメータ実験──数値を変えると何が起きるか

実験設計:変えるのは1パラメータずつ

ハイパーパラメータの影響を正確に把握するためには、一度に1つのパラメータだけを変更します(制御変数法)。複数を同時に変えると、どのパラメータが学習の変化を引き起こしたか特定できなくなります。

def run_experiment(alpha, gamma, epsilon_decay, n_episodes=3000, n_runs=5):
    """複数回実行して平均学習曲線を返す"""
    all_rewards = []
    
    for _ in range(n_runs):
        env = GridWorld()
        q_table = np.zeros((N_STATES, N_ACTIONS))
        epsilon = 1.0
        rewards = []
        
        for episode in range(n_episodes):
            state = env.reset()
            total_reward = 0
            done = False
            
            while not done:
                if np.random.random() < epsilon:
                    action = np.random.randint(N_ACTIONS)
                else:
                    action = np.argmax(q_table[state])
                
                next_state, reward, done = env.step(action)
                q_table[state, action] += alpha * (
                    reward + gamma * np.max(q_table[next_state]) - q_table[state, action]
                )
                state = next_state
                total_reward += reward
            
            epsilon = max(0.01, epsilon * epsilon_decay)
            rewards.append(total_reward)
        
        all_rewards.append(rewards)
    
    return np.mean(all_rewards, axis=0)

αを変えた場合:収束速度と安定性のトレードオフ

alphas = [0.01, 0.1, 0.5, 0.9]
results = {}
for alpha in alphas:
    rewards = run_experiment(alpha=alpha, gamma=0.99, epsilon_decay=0.995)
    final_avg = np.mean(rewards[-500:])
    results[alpha] = final_avg
    print(f"α = {alpha:.2f} | 最終500エピソード平均報酬: {final_avg:.3f}")

典型的な結果:

α 最終平均報酬 特徴
0.01 0.612 収束は遅いが安定。5000エピソードでもまだ上昇中
0.10 0.847 バランス最良。安定して高い報酬に収束
0.50 0.798 収束は速いが若干不安定
0.90 0.623 Q値が振動。直近の経験に引きずられすぎる

α = 0.9 では直近の経験が直前の学習を上書きし続けるため、Q値が安定しません。α = 0.1 が多くのタブラーQ学習タスクでのデファクト推奨値です。

γを変えた場合:経路の「欲張り度」が変わる

gammas = [0.5, 0.9, 0.99, 1.0]
for gamma in gammas:
    rewards = run_experiment(alpha=0.1, gamma=gamma, epsilon_decay=0.995)
    print(f"γ = {gamma:.2f} | 最終平均報酬: {np.mean(rewards[-500:]):.3f}")

γ = 0.5(近視眼的): 2〜3ステップ先の報酬しか考慮しないため、ゴールまでの経路が長いと価値が十分に伝播せず、遠いゴールへの動機が弱まります。

γ = 0.99(遠視眼的): ゴールから離れた状態でも、将来の +1 報酬がほぼ等価に伝播するため、長い経路でも最適行動を学習できます。

矢印マップで比較すると、γ = 0.5 では「グリッドの左上付近でゴール方向への明確な指向が弱くなる」現象が観察されます。

ε減衰速度を変えた場合

decays = [0.99, 0.995, 0.999, 0.9999]
for decay in decays:
    rewards = run_experiment(alpha=0.1, gamma=0.99, epsilon_decay=decay)
    # εが0.01に到達するエピソード数を計算
    ep_to_min = int(np.log(0.01) / np.log(decay))
    print(f"decay={decay} | ε→0.01 エピソード数: {ep_to_min:5d} | "
          f"最終報酬: {np.mean(rewards[-500:]):.3f}")

早すぎる収束(decay = 0.99): 約460エピソードでε = 0.01 に到達。状態空間を十分に探索できないまま活用に移行し、局所最適解に陥るリスクがあります。

遅すぎる収束(decay = 0.9999): 約46,000エピソードが必要。5000エピソードの学習期間中はほぼ探索フェーズが続き、サンプル効率が大幅に低下します。

推奨値(decay = 0.995): 約920エピソードでε = 0.01 に到達。5000エピソードの前半で探索し、後半で活用に集中できる理想的なバランスです。


第6章:発展──次のステップへの橋渡し

Q学習の限界:テーブル型が壊れる場面

GridWorld では16状態・4行動でQテーブルのサイズは 16×4 = 64 要素でした。しかし現実の問題では状態空間が爆発します。

CartPole の場合、状態は「カートの位置・速度・棒の角度・角速度」の4つの連続値です。これらを単純に10段階ずつ離散化しても、状態数は 10^4 = 10,000 になります。より細かく離散化すれば数百万〜数十億に達します。

np.digitize を使った離散化の例:

import numpy as np

# CartPole の状態範囲をビン(bin)に分割
cart_pos_bins    = np.linspace(-2.4, 2.4, 10)
cart_vel_bins    = np.linspace(-3.0, 3.0, 10)
pole_angle_bins  = np.linspace(-0.21, 0.21, 10)
pole_vel_bins    = np.linspace(-3.0, 3.0, 10)

def discretize(obs):
    """連続状態を離散インデックスに変換"""
    cart_pos, cart_vel, pole_angle, pole_vel = obs
    
    d_cart_pos   = np.digitize(cart_pos,   cart_pos_bins)
    d_cart_vel   = np.digitize(cart_vel,   cart_vel_bins)
    d_pole_angle = np.digitize(pole_angle, pole_angle_bins)
    d_pole_vel   = np.digitize(pole_vel,   pole_vel_bins)
    
    # 4次元インデックスを1次元に変換
    return (d_cart_pos * 10**3 + d_cart_vel * 10**2 + 
            d_pole_angle * 10 + d_pole_vel)

この離散化アプローチでも CartPole はある程度動作しますが、状態空間のカバレッジが粗くなるという根本的な限界は残ります。これがニューラルネットワークを導入する動機になります。

DQN(深層Q学習)との接続:何が変わり、何が変わらないか

DQN(Deep Q-Network)は、QテーブルをニューラルネットワークQ関数で置き換えた手法です。変わる点と変わらない点を整理します:

要素 テーブル型Q学習 DQN
Q値の表現 np.zeros((n_states, n_actions)) ニューラルネット(nn.Linear
Q値の更新 ベルマン方程式(同一) ベルマン方程式(同一)
行動選択 ε-greedy(同一) ε-greedy(同一)
学習 Q値の直接更新 損失関数+勾配降下
安定化技術 不要 Experience Replay + Target Network

ベルマン方程式は変わらない」——これがDQNを学ぶ際の最重要ポイントです。Q学習をNumPyスクラッチで理解したなら、DQNで「増えた部分」(Experience Replay、Target Network、勾配降下)に集中して学習できます。

Experience Replay が必要な理由:ニューラルネットは連続した相関の高いデータで学習すると不安定になります。過去の経験をバッファに貯め、ランダムにサンプリングして学習することで相関を断ち切ります。

Target Network が必要な理由:Qネットを更新しながら同じネットでターゲットを計算すると、目標値が動き続けて発散します。更新頻度の低いコピー(ターゲットネット)を使って安定させます。

RLHF・GRPOへの橋渡し:LLMとQ学習の共通語

LLMのファインチューニングとQ学習の概念的な対応:

Q学習の概念 RLHF/LLMでの対応
状態 s トークン列(プロンプト+生成済みトークン)
行動 a 次のトークンの選択
報酬 r 人間の評価スコア / 報酬モデルの出力
方策 π 言語モデル(確率分布)
価値関数 V Critic モデル

GRPO(Group Relative Policy Optimization)では、グループ内の応答を相対比較して報酬を計算し、「Criticモデルなしで」方策を更新します。これはQ値ではなくアドバンテージ関数(A = Q - V)の概念に直接つながります。

「Qテーブルで価値を直接操作する感覚」を持つ人は、こうした抽象的な議論を格段に素早く理解できます。Q学習は単なる入門課題ではなく、LLMの内部メカニズムへの橋渡しとなる本質的な概念体系です。


まとめ:「コード逆引き」で得られた本当の理解

この記事で実装・解剖した内容を振り返りましょう。

5つのキーワードの再整理:

  1. Qテーブル = np.zeros((n_states, n_actions)) — 知識ゼロからスタートする状態×行動の価値行列
  2. ベルマン方程式 = += ALPHA * (reward + GAMMA * np.max(...) - q_table[s, a]) — TD誤差で少しずつ正解に近づく唯一のルール
  3. ε-greedy = if np.random.random() < EPSILON — 探索と活用を確率で切り替える判断基準
  4. α(学習率) = ALPHA = 0.1 — 記憶の更新速度。大きすぎれば発散、小さすぎれば遅延
  5. γ(割引率) = GAMMA = 0.99 — 遠視眼の度合い。ゴールまでの価値の伝播速度を決める

NumPyスクラッチ実装の最大の価値は、「Q値が行列の数字として手で触れる」体験にあります。フレームワークが抽象化してしまう「強化学習の素の骨格」——報酬を受け取り、Q値を更新し、徐々に賢くなるエージェントの本質的なメカニズム——が、わずか30行のNumPyコードに凝縮されています。

次に試すべき環境・リソース:

  • FrozenLake-v1(OpenAI Gymnasium):スリップ(確率的遷移)あり。確定的なGridWorldとの違いを体感できる
  • Taxi-v3:状態数500、行動数6。テーブル型Q学習の限界に近い規模
  • CartPole-v1:連続状態の離散化か、DQNへの移行かを判断する試金石
  • CleanRL:DQN〜PPOまで「1ファイル実装」で学べる次のステップ

付録

A. 完全なGridWorld環境クラス実装

import numpy as np

class GridWorld:
    """
    4×4 GridWorld 環境
    
    グリッド構成:
        S . . .
        . W . .
        . . W .
        . . . G
    
    S: スタート(0,0)  G: ゴール(3,3)
    W: 壁  .: 通路
    """
    
    # 行動定数
    UP, DOWN, LEFT, RIGHT = 0, 1, 2, 3
    MOVES = [(-1, 0), (1, 0), (0, -1), (0, 1)]
    
    def __init__(self):
        self.n_rows    = 4
        self.n_cols    = 4
        self.n_states  = self.n_rows * self.n_cols
        self.n_actions = 4
        
        # 0: 通路, 1: 壁, 2: ゴール
        self.grid = np.array([
            [0, 0, 0, 0],
            [0, 1, 0, 0],
            [0, 0, 1, 0],
            [0, 0, 0, 2],
        ])
        
        self.walls     = {(1, 1), (2, 2)}
        self.start_pos = (0, 0)
        self.goal_pos  = (3, 3)
        self.state     = 0
    
    def reset(self):
        """環境をリセット。スタート状態のインデックスを返す"""
        row, col   = self.start_pos
        self.state = self._pos_to_idx(row, col)
        return self.state
    
    def step(self, action):
        """
        action: 0=上, 1=下, 2=左, 3=右
        returns: (next_state, reward, done)
        """
        row, col = self._idx_to_pos(self.state)
        d_row, d_col = self.MOVES[action]
        new_row = row + d_row
        new_col = col + d_col
        
        # 境界チェック・壁チェック
        if (not (0 <= new_row < self.n_rows and
                 0 <= new_col < self.n_cols) or
                (new_row, new_col) in self.walls):
            return self.state, -1.0, False
        
        self.state = self._pos_to_idx(new_row, new_col)
        
        if (new_row, new_col) == self.goal_pos:
            return self.state, 1.0, True
        
        return self.state, -0.01, False
    
    def _pos_to_idx(self, row, col):
        return row * self.n_cols + col
    
    def _idx_to_pos(self, idx):
        return idx // self.n_cols, idx % self.n_cols
    
    def render(self, q_table=None):
        """グリッドと(オプションで)方策を表示"""
        ARROWS = ['↑', '↓', '←', '→']
        print("+" + "---+" * self.n_cols)
        
        for row in range(self.n_rows):
            line = "|"
            for col in range(self.n_cols):
                if (row, col) == self.goal_pos:
                    line += " ★ |"
                elif (row, col) in self.walls:
                    line += " ■ |"
                elif (row, col) == self._idx_to_pos(self.state):
                    line += " A |"  # エージェント位置
                elif q_table is not None:
                    idx = self._pos_to_idx(row, col)
                    best = np.argmax(q_table[idx])
                    line += f" {ARROWS[best]} |"
                else:
                    line += "   |"
            print(line)
            print("+" + "---+" * self.n_cols)

B. ハイパーパラメータ一覧と推奨レンジ

パラメータ 推奨初期値 探索レンジ 影響
ALPHA(学習率) 0.1 0.01 〜 0.5 収束速度・安定性
GAMMA(割引率) 0.99 0.9 〜 0.999 将来報酬の重視度
EPSILON(初期探索率) 1.0 0.5 〜 1.0 初期探索量
EPSILON_MIN(最小探索率) 0.01 0.001 〜 0.05 定常探索量
EPSILON_DECAY(減衰係数) 0.995 0.99 〜 0.9999 探索→活用の切替速度
EPISODES(学習エピソード数) 5000 1000 〜 50000 学習の十分性

C. よくあるバグと原因逆引き表

症状 疑うべき原因 対処法
Q値が NaN になる α が大きすぎる / 報酬スケールが極端に大きい α を 0.1 以下に下げる。報酬を正規化(-1〜+1)
学習が全く進まない(報酬が改善しない) ε が減衰していない / γ = 0 になっている 減衰スケジュールを確認。print(EPSILON) で追跡
最適経路に収束しない エピソード数不足 / γ が小さすぎる エピソード数を倍増。γ を 0.99 に設定
ゴールに全くたどり着けない 報酬がスパースすぎる 時間ペナルティ(-0.01)を追加して密な報酬に
特定の行動しか選ばない(偏り) タイブレーク問題 / ε の初期値が小さい ε = 1.0 から開始。楽観的初期値の検討
学習後も矢印がランダムに見える 学習ループで state = next_state を忘れている ループ末尾で状態更新を確認
報酬が振動して収束しない α が大きすぎる / ε 減衰が遅すぎる α を 0.05〜0.1 に、decay を 0.995 に調整

関連記事

機械学習による仮想通貨価格予測(第4部):強化学習で収益を最大化するトレードAIの実装ガイド
機械学習・AI

機械学習による仮想通貨価格予測(第4部):強化学習で収益を最大化するトレードAIの実装ガイド

強化学習(PPO・DQN・SAC)を使った仮想通貨トレードAIの実装を解説。カスタムGym環境の構築からstable-baselines3による学習まで、Pythonコードで段階的に説明します。

機械学習による仮想通貨価格予測(第3部):予測モデルの検証と自動売買システムへの統合完全ガイド
機械学習・AI

機械学習による仮想通貨価格予測(第3部):予測モデルの検証と自動売買システムへの統合完全ガイド

バックテスト成功でも本番失敗する原因を解説。ウォークフォワード検証・CPCVなどプロの検証手法と、自動売買システムへの統合・運用まで実装コード付きで完全解説。

機械学習による仮想通貨価格予測(第2部):予測モデル実装と精度比較の完全ガイド
機械学習・AI

機械学習による仮想通貨価格予測(第2部):予測モデル実装と精度比較の完全ガイド

LSTM・TFT・XGBoost・LightGBMを使った仮想通貨価格予測モデルの実装と精度比較を徹底解説。ウォークフォワード検証・アンサンブル学習・本番導入まで網羅した完全ガイド。

コメント

0/2000