【フィジカルAI入門 #09】強化学習(RL) ― 報酬だけを手がかりに、ロボットは自分で動き方を学べるか
はじめに
第8章までで、Part 3「シミュレーション」が終わりました。ロボットを動かすための数学(第3〜5章)と、試行錯誤の場であるシミュレータ(第6〜8章)がそろったことになります。
ここまで、ロボットの「考える部分」、つまり第0章の think() は、すべて 人間が数式で書いて きました。ヤコビ転置法、逆運動学、PID 制御。どれも「ロボットの形と物理がわかっていれば、正しい動かし方を計算できる」という前提に立っています。
本章から Part 4「ロボット学習」に入ります。最初に扱うのは 強化学習(Reinforcement Learning、RL) です。強化学習では、正しい動かし方を教えません。「うまくいったら点数を与える」というルール(報酬)だけを決め、あとはロボットが試行錯誤で動き方を見つけます。
本章のゴールは次の4つです。
- 強化学習の考え方と、マルコフ決定過程(MDP) の5つの要素を、第7章の並列環境のコードと対応づけて理解する
- PPO と SAC という代表的なアルゴリズムを、数式の導出なしに直感で理解し、使い分けを知る
- 強化学習で最も難しいと言われる 報酬設計 の勘所(dense と sparse、行動のペナルティ、報酬ハッキング)を、実験で確かめる
- 第7章の Reach 環境を Stable-Baselines3 の PPO で学習し、第0章の手書きの制御器と比べる
:::note info
前提と実行環境
- 本章のコードは CPU だけで動きます(GPU は不要)。筆者の環境では、1回の学習が5分前後で終わります
- 強化学習ライブラリ Stable-Baselines3、Gymnasium、PyTorch(CPU 版で十分)が必要です。インストール方法は 4.1 節を参照してください
- 第7章の vec_env.py(N 環境を一括で計算する Reach 環境)を土台にします。第7章を読んでいると理解しやすくなります
:::
---
1. 強化学習とは
1.1 正解を教えずに、点数だけを教える
ソフトウェアエンジニアに身近な例で考えてみます。Web サイトのボタンの色を決めるとき、正解の色を知っている人はいません。そこで A/B テスト をして、クリック率の高いほうを選びます。さらに、「よく効いた案を多めに出しつつ、ほかの案もときどき試す」ように自動化したものが、推薦システムなどで使われる 多腕バンディット です。
強化学習は、この考え方を 「一連の行動」 に広げたものです。
| | A/B テスト・バンディット | 強化学習 |
|---|---|---|
| 1回の試行 | 1つの案を見せる | 状態を見て行動する、を何十〜何百回も繰り返す |
| 評価 | クリックしたか | 各ステップの報酬の合計 |
| 難しさ | どの案がよいか | どの時点のどの行動が、最終的な結果に効いたのか(信用割り当て問題) |
ロボットの Reach タスクで言えば、「手先が目標に届いた」という結果は、それまでの 50 回分のトルクの出し方すべての結果です。どのトルクがよかったのかを、試行錯誤の結果から逆算していくのが強化学習の中心的な難しさです。
1.2 マルコフ決定過程(MDP)
強化学習では、問題を マルコフ決定過程(MDP) という形に整理します。難しそうな名前ですが、中身は5つの要素だけです。第7章の vec_env.py は、実はこの5つをそのまま実装していました。
| 要素 | 意味 | vec_env.py での実装 |
|---|---|---|
| 状態 $s$ | 今の状況 | _get_observations():関節角(cos, sin)、関節速度、目標位置、目標までのずれ(10次元) |
| 行動 $a$ | エージェントが選べること | step(actions) の引数:2つの関節トルク($[-1, 1]$ に正規化) |
| 遷移 $P(s' \mid s, a)$ | 行動の結果、次にどうなるか | dynamics():運動方程式を 10 ステップ積分 |
| 報酬 $r$ | その行動がどれだけよかったか | _get_rewards():目標との距離にマイナスを付けたもの、など |
| 割引率 $\gamma$ | 将来の報酬をどれだけ重視するか | 学習側の設定(本章では $\gamma = 0.99$) |
「マルコフ」とは、次に何が起きるかは、今の状態と行動だけで決まる(過去の履歴は要らない)という性質です。観測に関節速度が入っているのはこのためです。角度だけでは、腕が今どちら向きに動いているのかわからず、次の状態を予測できません。
1.3 方策と収益
エージェントが学ぶのは 方策(policy) $\pi(a \mid s)$、つまり「状態を見て行動を決める関数」です。第0章の think() そのものです。本章では、方策を小さなニューラルネットワーク(全結合層 2 層)で表します。
学習の目標は、1エピソードを通した報酬の合計(収益)を最大にすることです。
$$
G = r_0 + \gamma r_1 + \gamma^2 r_2 + \cdots
$$
割引率 $\gamma$ は「遠い将来の報酬ほど割り引く」ための係数で、$\gamma = 0.99$ なら、だいたい $1/(1-\gamma) = 100$ ステップ先までを見通すことになります。本章の環境では方策が 50Hz で動くので、約2秒先です。
収益の期待値を状態ごとに見積もったものを 価値関数 $V(s)$ と呼びます。「この状態からなら、この先どれくらいの報酬が期待できるか」を表す関数で、強化学習のアルゴリズムの多くが、方策と一緒にこれを学習します。[第1章](./01_ToolCallingからActionへ.md)で紹介した SayCan の「アフォーダンス」も、実はこの価値関数でした(5.2 節)。
---
2. 方策勾配から PPO へ
2.1 方策勾配:うまくいった行動を、少しだけ多めに
ニューラルネットワークの方策を、どうやって改善すればよいのでしょうか。教師あり学習なら「正解の行動」との誤差を小さくすればよいのですが、強化学習には正解がありません。
方策勾配法 の考え方は、次のとおりです。
1. 今の方策で、少しランダムさを加えながら行動してみる
2. 平均より結果がよかった行動 は、次から選ばれやすくなるように、ネットワークの重みを少し動かす
3. 平均より悪かった行動 は、選ばれにくくする
「平均よりどれだけよかったか」を アドバンテージ と呼びます。平均(ベースライン)の見積もりに、1.3 節の価値関数を使います。方策(Actor、行動する役)と価値関数(Critic、評価する役)を同時に学習するので、このような構成を Actor-Critic と呼びます。
2.2 PPO:一度に変えすぎない
素朴な方策勾配法には、1回の更新で方策が大きく変わりすぎて、学習が崩壊しやすい という弱点があります。たまたまうまくいった行動に飛びつきすぎて、それまでに覚えたことを壊してしまうのです。
PPO(Proximal Policy Optimization) は、2017年に OpenAI が発表したアルゴリズムで、この問題を 「新しい方策と古い方策で、行動の選ばれやすさの比が $[1-\epsilon, 1+\epsilon]$ の範囲を超えたら、それ以上は得をしない」 というクリッピングで抑えます[^ppo]。$\epsilon$ は 0.2 がよく使われ、「1回の更新で、ある行動の確率を 2 割を超えて変えても、得をしない」というイメージです。
この制限のおかげで、同じデータを 何回も使い回して 更新しても安全になります(本章の設定では 10 回)。実装が簡単で、ハイパーパラメータにも比較的鈍感なため、PPO は現在も ロボット学習の標準的なアルゴリズム です。第7章で触れた Isaac Lab の RSL-RL も、PPO を中心にしたライブラリです。
ソフトウェア開発に例えるなら、カナリアリリース に近い考え方です。新しいバージョン(方策)がよさそうでも、一気に全部を切り替えず、変化の幅を制限しながら少しずつ移行します。
2.3 SAC:過去の経験も使い回す
PPO は オンポリシー のアルゴリズムで、「今の方策で集めたデータ」しか学習に使えません。方策を更新したら、それまでのデータは捨てて、また集め直します。シミュレータで大量のデータを安く集められるなら問題ありませんが、実機ではデータ集めが高くつきます。
SAC(Soft Actor-Critic) は、2018年に UC Berkeley のグループが発表した オフポリシー のアルゴリズムです[^sac]。過去の経験をすべて リプレイバッファ に貯めておき、何度でも学習に使います。また、報酬に加えて 行動のランダムさ(エントロピー)も大きく保つ ことを目標にするのが特徴で、「タスクをこなしつつ、できるだけいろいろな動き方を試す」ように学習します。
SAC の論文の著者らは、4脚ロボット Minitaur の歩行や、ブロックの積み上げを、実機の上で約2時間 の試行錯誤で学習させています[^sac-blog]。
2.4 どちらを使うか
| | PPO | SAC |
|---|---|---|
| データの使い方 | オンポリシー(集めたら捨てる) | オフポリシー(貯めて使い回す) |
| サンプル効率 | 低い(大量の試行が必要) | 高い |
| 並列化との相性 | よい(数千環境で一気に集めて、まとめて更新する) | 比較的悪い(集めた経験 1 ステップごとに、ネットワークを更新するのが基本) |
| 向いている場面 | GPU 並列シミュレーション、歩行の学習 | 実機での学習、シミュレーションが重い場合 |
第7章で見たように、現在のロボット学習では シミュレータで数千環境を並列に動かす のが標準です。データが安く大量に手に入るので、サンプル効率より安定性と速さを重視して、PPO が選ばれることが多くなっています。本章でも PPO を使います。
---
3. 報酬設計
強化学習では、報酬が「仕様書」 です。エージェントは、仕様書に書かれたことを、書かれたとおりに、徹底的に最適化します。書かれていないことは一切考慮しません。ここに、強化学習で最も難しいと言われる 報酬設計 の問題があります。
3.1 dense と sparse
Reach タスクの報酬は、大きく2通りに書けます。
| 種類 | 例 | 長所 | 短所 |
|---|---|---|---|
| sparse(疎な報酬) | 目標に届いたら 1、それ以外は 0 | 仕様として明快。「何をしてほしいか」がそのまま書ける | 偶然届くまで何も学べない |
| dense(密な報酬) | 毎ステップ「−(目標までの距離)」 | 少し近づくだけで改善がわかり、学習が速い | 「距離を縮める」ことが本当の目的と一致している保証はない |
sparse 報酬では、ランダムな動きで偶然目標に届くまで、報酬はずっと 0 です。報酬が 0 のままでは、どの行動がよかったのかの手がかりがありません。これを 探索の問題 と呼び、疎で二値の報酬からどう学ぶかは、それ自体が研究テーマになっています[^her]。
3.2 書かなかったことは守られない
dense 報酬で「距離を縮める」ことだけを報酬にすると、エージェントにとって、動きのなめらかさやモーターへの負担は、どうでもよいこと になります。距離さえ縮まれば、トルクを急に切り替えようが、見た目がぎこちなかろうが、報酬には関係ないからです。
そこで、人間が暗黙に期待していた仕様を、報酬として明文化します。第7章の報酬にある -0.01 * Σa² は、行動(トルク)の大きさ へのペナルティで、「必要以上の力を出さない」ことを求めます。ただし、これは「急に切り替えない」こと、つまりなめらかさを直接求めるものではありません。なめらかさまで求めたいなら、前のステップとの差 $\sum (a_t - a_{t-1})^2$ にペナルティを付けます。脚ロボットの強化学習では、このような項(action rate)がよく使われます。どれくらい効くかは、4.6 節の実験で確かめます。
3.3 報酬ハッキング
報酬の書き方に抜け穴があると、エージェントはそれを見つけて悪用します。これを 報酬ハッキング(または 仕様ゲーミング)と呼びます。DeepMind は仕様ゲーミングを「目的の文字どおりの仕様は満たしているのに、意図した結果は達成していない振る舞い」と定義し、多数の事例を集めています[^specgaming]。
有名な例が、OpenAI が 2016年に紹介したボートレースゲーム CoastRunners です。コース上のアイテムを取ると得点が入る報酬で学習させたところ、エージェントは レースを完走せず、同じ場所をぐるぐる回ってアイテムを取り続ける 方法を見つけ、完走するよりも高い得点を出しました[^coastrunners]。
3.4 抜け穴のない「整形」:ポテンシャル型の報酬
学習を速くするために、sparse な本来の報酬に「ヒント」を足すことを 報酬整形(reward shaping) と呼びます。しかし、3.3 節のように、足したヒントそのものが抜け穴になることがあります。
Ng らは 1999年に、ヒントを「ポテンシャルの差」の形で足せば、最適な方策は変わらない ことを示しました[^ng]。
$$
F(s, s') = \gamma \Phi(s') - \Phi(s)
$$
$\Phi(s)$ は「状態のよさ」を表す任意の関数で、Reach なら「−(目標までの距離)」が自然です。近づけば $F$ はプラス、遠ざかればマイナスになります。ポイントは、行って戻ってくると、もらった分とほぼ同じだけ引かれる ことです。ぐるぐる回って稼ぐことができないので、ヒントが抜け穴になりません。
4.6 節では、この「抜け穴のあるヒント」と「ポテンシャル型のヒント」を、実際に学習させて比べます。
---
4. 実践:第7章の Reach 環境を PPO で学習する
4.1 準備とファイル構成
強化学習の実装には、Stable-Baselines3(SB3) を使います。PyTorch で書かれた、PPO・SAC などの代表的なアルゴリズムの信頼性の高い実装を集めたライブラリです[^sb3-paper]。執筆時点の最新版は 2.9.0 です[^sb3-pypi]。
# uv の場合
uv sync --extra rl
# pip の場合(PyTorch は CPU 版を指定すると、ダウンロードが小さく済む)
# 先に CPU 版の PyTorch を入れる(順番が逆だと、Linux では GPU 版の大きなパッケージが入る)
pip install torch --index-url https://download.pytorch.org/whl/cpu
pip install stable-baselines3 gymnasium
ch09_reinforcement_learning/
├── vec_env.py # 第7章の Reach 並列環境(報酬を切り替えられるように拡張)
├── sb3_env.py # vec_env を Stable-Baselines3 の VecEnv として使うアダプタ
├── evaluate.py # 方策の評価(成功率・平均ステップ数・トルク変化率)
├── train_ppo.py # PPO で学習する
└── reward_experiments.py # 報酬を5通りに変えて、学習結果を比べる
タスクは第7章と同じです。平面2関節アームの手先を、ランダムな目標位置へ動かします。目標から 1 cm 以内に入り、関節速度がほぼ 0(0.2 rad/s 未満)になったら成功 で、3秒以内に成功しなければ時間切れです。手先を「目標の近くで止める」ところまでが求められる、意外と厳しい条件です。
4.2 環境:vec_env.py
第7章の vec_env.py をコピーし、次の3点だけを変えました。運動方程式・観測・成功判定は第7章と同じです。
1. 報酬を reward 引数で切り替えられる ようにした(4.6 節の実験用)。"dense" が第7章と同じ報酬
2. step() が、Gymnasium の流儀に合わせて 成功による終了(terminated)と時間切れ(truncated)を分けて 返すようにした。時間切れは「失敗」ではなく「途中で打ち切った」だけなので、学習アルゴリズムはこの2つを区別して扱います
3. 終わった環境について、リセットする前の観測(final_obs) も返すようにし、step() が返す観測はリセット後のものにした
3つ目は、実は第7章のコードの小さな不具合の修正でもあります。第7章の step() は、エピソードが終わった環境についても リセット前の観測 を返していたため、リセット直後の1ステップだけ、方策が前のエピソードの観測を見て行動していました。第7章の目的(スループットの比較)には影響しませんが、強化学習では「どの観測でどの行動をしたか」を正確に記録する必要があるので、ここで直しています。この修正と、評価の条件(4.4 節)の違いにより、ヤコビ転置法の成功率は、第7章の 87.5% とはわずかに異なる 86.8% になります。
"""第9章: 強化学習で使う、2関節アームの Reach 並列環境
第7章の vec_env.py をコピーし、次の点だけを変えている(動力学・観測・成功判定は同じ)。
- 報酬関数を reward 引数で切り替えられるようにした(報酬設計の実験用)
- step() が Gymnasium と同じく terminated(成功)と truncated(時間切れ)を分けて返す
- 自動リセットの前の観測 final_obs も返す(学習ライブラリが最後の状態の価値を計算するため)
"""
import numpy as np
L1, L2 = 0.30, 0.25 # リンク長 [m]
M1, M2 = 1.0, 0.5 # リンク先端の質量 [kg](質点モデル)
DAMP = 0.5 # 関節の粘性
DT, DECIMATION = 0.002, 10 # 物理 500Hz、方策 50Hz(物理10ステップごとに行動を1回決める)
EPISODE_STEPS = 150 # 1エピソード = 3秒
TAU_MAX = 5.0
REWARD_MODES = ("dense", "no_action_penalty", "sparse", "progress", "potential")
GAMMA = 0.99 # 割引率(potential の整形に使う。PPO の gamma と揃える)
PHI_SCALE = 0.01 # potential のスケール [m](1cm 近づくと +1)
SUCCESS_BONUS = 10.0 # potential で成功したときのボーナス
def dynamics(q, qd, tau):
"""平面2関節アームの運動方程式 M(q) q̈ + c(q, q̇) + D q̇ = τ を一括で解く(形状 (N, 2))"""
c2, s2 = np.cos(q[:, 1]), np.sin(q[:, 1])
m11 = (M1 + M2) * L1**2 + M2 * L2**2 + 2 * M2 * L1 * L2 * c2
m12 = M2 * L2**2 + M2 * L1 * L2 * c2
m22 = np.full_like(m11, M2 * L2**2)
h = M2 * L1 * L2 * s2
c = np.stack([-h * (2 * qd[:, 0] * qd[:, 1] + qd[:, 1] ** 2), h * qd[:, 0] ** 2], axis=1)
rhs = tau - c - DAMP * qd
det = m11 * m22 - m12**2 # 2x2 の逆行列を解析的に
qdd = np.stack([(m22 * rhs[:, 0] - m12 * rhs[:, 1]) / det,
(-m12 * rhs[:, 0] + m11 * rhs[:, 1]) / det], axis=1)
return qdd
def fk(q):
"""手先位置 (N, 2)"""
return np.stack([L1 * np.cos(q[:, 0]) + L2 * np.cos(q[:, 0] + q[:, 1]),
L1 * np.sin(q[:, 0]) + L2 * np.sin(q[:, 0] + q[:, 1])], axis=1)
def jacobian(q):
"""手先のヤコビアン (N, 2, 2)(第4章)"""
s1, c1 = np.sin(q[:, 0]), np.cos(q[:, 0])
s12, c12 = np.sin(q[:, 0] + q[:, 1]), np.cos(q[:, 0] + q[:, 1])
return np.stack([np.stack([-L1 * s1 - L2 * s12, -L2 * s12], axis=1),
np.stack([L1 * c1 + L2 * c12, L2 * c12], axis=1)], axis=1)
class VecReachEnv:
"""N 環境を一括で扱う Reach 環境(第7章と同じ構成。報酬だけ切り替えられる)"""
def __init__(self, num_envs, seed=0, reward="dense"):
if reward not in REWARD_MODES:
raise ValueError(f"reward は {REWARD_MODES} のいずれか: {reward!r}")
self.n = num_envs
self.reward = reward
self.rng = np.random.default_rng(seed)
self.q = np.zeros((num_envs, 2)); self.qd = np.zeros((num_envs, 2))
self.target = np.zeros((num_envs, 2))
self.step_count = np.zeros(num_envs, dtype=int)
self.actions = np.zeros((num_envs, 2)); self.tau = np.zeros((num_envs, 2))
self.prev_dist = np.zeros(num_envs)
self.successes = 0; self.episodes = 0
def reset(self):
"""全環境を初期化して、観測 (N, 10) を返す"""
self._reset_idx(np.arange(self.n))
return self._get_observations()
# ---- 行動を受け取り、物理を DECIMATION ステップ進める ----
def step(self, actions):
self.actions = np.clip(actions, -1.0, 1.0) # 方策の出力は [-1, 1] に正規化
for _ in range(DECIMATION):
self._apply_action()
self.qd += dynamics(self.q, self.qd, self.tau) * DT # 半陰的オイラー積分
self.q += self.qd * DT
self.step_count += 1
obs = self._get_observations()
success, timeout = self._get_dones()
rew = self._get_rewards(success)
terminated = success # 成功してエピソードが終わった
truncated = timeout & ~success # 時間切れで打ち切られた
done = terminated | truncated
self.successes += int(success.sum()); self.episodes += int(done.sum())
final_obs = obs.copy() # リセット前の「最後の観測」
if done.any(): # 終わった環境だけをその場でリセット
ids = np.nonzero(done)[0]
self._reset_idx(ids)
obs[ids] = self._get_observations()[ids]
return obs, rew, terminated, truncated, final_obs
def _apply_action(self):
self.tau = TAU_MAX * self.actions
def _get_observations(self):
return np.concatenate([np.cos(self.q), np.sin(self.q), self.qd,
self.target, self.target - fk(self.q)], axis=1) # (N, 10)
def _get_rewards(self, success):
dist = np.linalg.norm(self.target - fk(self.q), axis=1)
if self.reward == "dense": # 第7章と同じ:距離 + 行動の大きさのペナルティ
rew = -dist - 0.01 * np.square(self.actions).sum(axis=1)
elif self.reward == "no_action_penalty": # 距離だけ
rew = -dist
elif self.reward == "sparse": # 成功したときだけ 1
rew = success.astype(float)
elif self.reward == "progress": # 近づいたら +1、遠ざかっても罰はない(抜け穴あり)
rew = (dist < self.prev_dist).astype(float)
else: # potential:γΦ(s') − Φ(s)、Φ = −距離
phi, prev_phi = -dist / PHI_SCALE, -self.prev_dist / PHI_SCALE
rew = GAMMA * phi - prev_phi + SUCCESS_BONUS * success
self.prev_dist = dist
return rew
def _get_dones(self):
dist = np.linalg.norm(self.target - fk(self.q), axis=1)
speed = np.linalg.norm(self.qd, axis=1)
return (dist < 0.01) & (speed < 0.2), self.step_count >= EPISODE_STEPS
def _reset_idx(self, ids):
"""指定した環境だけ初期化する(関節角と目標位置をランダム化)"""
k = len(ids)
self.q[ids] = self.rng.uniform(-np.pi, np.pi, (k, 2))
self.q[ids, 1] = self.rng.uniform(0.3, 2.5, k) * self.rng.choice([-1, 1], k) # 特異姿勢を避ける
self.qd[ids] = 0.0
r = self.rng.uniform(0.15, 0.50, k); th = self.rng.uniform(-np.pi, np.pi, k)
self.target[ids] = np.stack([r * np.cos(th), r * np.sin(th)], axis=1)
self.step_count[ids] = 0
self.prev_dist[ids] = np.linalg.norm(self.target[ids] - fk(self.q[ids]), axis=1)
def scripted_policy(obs):
"""比較用の方策:第0章のヤコビ転置法 + ダンピング(一括で計算)"""
q = np.arctan2(obs[:, 2:4], obs[:, 0:2])
qd, err = obs[:, 4:6], obs[:, 8:10]
tau = 100.0 * np.einsum("nij,ni->nj", jacobian(q), err) - 1.5 * qd # τ = Kp Jᵀe − Kd q̇
return tau / TAU_MAX
5種類の報酬は、3章の内容に対応しています。
| reward | 定義 | ねらい |
|---|---|---|
| dense | $-d - 0.01 \sum a^2$ | 第7章と同じ。距離 $d$ と行動の大きさのペナルティ |
| no_action_penalty | $-d$ | 行動のペナルティなし(3.2 節) |
| sparse | 成功したステップだけ 1 | 疎な報酬(3.1 節) |
| progress | 前のステップより近づいたら +1 | 抜け穴のあるヒント(3.3 節) |
| potential | $\gamma\Phi(s') - \Phi(s)$ + 成功で +10 | ポテンシャル型のヒント(3.4 節)。$\Phi = -d / 0.01$ |
4.3 SB3 につなぐ:sb3_env.py
SB3 には、複数の環境をまとめて扱う VecEnv というインタフェースがあります。「N 環境分の行動を受け取り、N 環境分の観測・報酬・終了フラグを返す」という、第7章の並列環境とまったく同じ考え方です。そこで、VecReachEnv を VecEnv の形に包むだけの薄いアダプタを書きます。計算はすべて VecReachEnv が配列で一括に行います。
"""第9章: VecReachEnv を Stable-Baselines3 の VecEnv として使うためのアダプタ
SB3 の VecEnv は「N 環境をまとめて step する」インタフェースで、第7章の並列環境と同じ考え方。
中身の計算はすべて VecReachEnv が配列で一括に行い、ここでは型と info の形式を合わせるだけ。
"""
import numpy as np
from gymnasium import spaces
from stable_baselines3.common.vec_env import VecEnv
from vec_env import VecReachEnv
class SB3ReachEnv(VecEnv):
def __init__(self, num_envs, seed=0, reward="dense"):
self.raw = VecReachEnv(num_envs, seed=seed, reward=reward)
self.raw.render_mode = None # SB3 が問い合わせる属性(描画はしない)
super().__init__(
num_envs,
spaces.Box(-np.inf, np.inf, (10,), np.float32), # 観測:cos q, sin q, q̇, 目標, 目標 − 手先
spaces.Box(-1.0, 1.0, (2,), np.float32), # 行動:正規化した関節トルク
)
def reset(self):
return self.raw.reset().astype(np.float32)
def step_async(self, actions):
self._actions = actions
def step_wait(self):
obs, rew, terminated, truncated, final_obs = self.raw.step(self._actions)
dones = terminated | truncated
infos = [{} for _ in range(self.num_envs)]
for i in np.nonzero(dones)[0]:
infos[i]["terminal_observation"] = final_obs[i].astype(np.float32) # 最後の状態の価値の計算に使う
infos[i]["TimeLimit.truncated"] = bool(truncated[i]) # 時間切れは「失敗で終了」と区別する
infos[i]["is_success"] = bool(terminated[i])
return obs.astype(np.float32), rew.astype(np.float32), dones, infos
def close(self):
pass
# ---- 以下は SB3 の VecEnv が要求するメソッド(この章では最小限の実装) ----
def get_attr(self, attr_name, indices=None):
return [getattr(self.raw, attr_name)] * len(list(self._get_indices(indices)))
def set_attr(self, attr_name, value, indices=None):
setattr(self.raw, attr_name, value)
def env_method(self, method_name, *method_args, indices=None, **method_kwargs):
return [getattr(self.raw, method_name)(*method_args, **method_kwargs)]
def env_is_wrapped(self, wrapper_class, indices=None):
return [False] * len(list(self._get_indices(indices)))
terminal_observation と TimeLimit.truncated が、4.2 節の変更点 2・3 に対応しています。時間切れで打ち切ったエピソードについては、PPO は「この先もエピソードが続いていたら得られたはずの報酬」を、最後の観測の価値関数で見積もって補います。そのために、リセット前の観測が必要になるのです。
4.4 評価:evaluate.py
学習した方策を、学習とは別のシードで生成した 1024 環境 × 10 秒 で評価します。主な指標は4つです。
- 成功率:終わったエピソードのうち、成功したものの割合
- 平均ステップ:成功したエピソードが、何ステップ(1ステップ = 0.02 秒)で成功したか
- トルク変化率:1ステップあたりのトルクの変化量を時間で割ったもの [Nm/s]。[第5章](./05_制御と軌道生成.md)でも使った「動きのなめらかさ」の指標で、小さいほどモーターにやさしい
- 時間切れ時の距離:時間切れになったエピソードで、最後に目標まで何 cm 残っていたか(中央値)。失敗の「中身」を見るための指標
"""第9章: 方策を評価する(成功率・平均ステップ数・トルクの変化率・時間切れ時の距離・報酬の合計)
使い方:
python evaluate.py # 比較用のヤコビ転置法だけを評価
python evaluate.py models/ppo_dense.zip # 学習済みモデルも並べて評価
"""
import sys
from pathlib import Path
import numpy as np
from vec_env import DECIMATION, DT, VecReachEnv, scripted_policy
def evaluate(policy, num_envs=1024, steps=500, seed=1, reward="dense"):
"""policy(obs (N,10)) -> 行動 (N,2) を num_envs 環境 × steps ステップ動かして統計を取る
学習(seed=0)とは別のシードで、まだ見ていない初期姿勢・目標を使って評価する。
"""
env = VecReachEnv(num_envs, seed=seed, reward=reward)
obs = env.reset()
ep_return, ep_len = np.zeros(num_envs), np.zeros(num_envs, dtype=int)
returns, success_steps, timeout_dists = [], [], []
prev_tau = np.zeros((num_envs, 2))
same_episode = np.zeros(num_envs, dtype=bool) # 前のステップと同じエピソードか(初回とリセット直後は False)
dtau_sum, dtau_count = 0.0, 0
for _ in range(steps):
obs, rew, terminated, truncated, final_obs = env.step(policy(obs))
# トルクの変化率 |Δτ| / Δt [Nm/s](クリップ後に実際にかかったトルクで計算)
dtau = np.abs(env.tau - prev_tau)[same_episode] / (DT * DECIMATION)
dtau_sum += dtau.sum(); dtau_count += dtau.size
prev_tau = env.tau.copy()
ep_return += rew; ep_len += 1
done = terminated | truncated
returns += ep_return[done].tolist()
success_steps += ep_len[terminated].tolist()
timeout_dists += np.linalg.norm(final_obs[truncated, 8:10], axis=1).tolist() # 時間切れの時点で、目標まで何 m 残っていたか
ep_return[done] = 0.0; ep_len[done] = 0
same_episode = ~done
return {
"success_rate": env.successes / max(env.episodes, 1),
"episodes": env.episodes,
"mean_steps": float(np.mean(success_steps)) if success_steps else float("nan"),
"mean_return": float(np.mean(returns)) if returns else float("nan"),
"torque_rate": dtau_sum / max(dtau_count, 1),
"timeout_dist": float(np.median(timeout_dists)) if timeout_dists else float("nan"),
}
def sb3_policy(model, vecnorm=None):
"""SB3 のモデルを policy(obs) の形にする(評価では確率的なノイズを入れず、平均の行動を使う)
学習時に観測を正規化していた場合は、評価でも同じ統計で正規化してから方策に渡す。
"""
from stable_baselines3.common.vec_env import VecNormalize
norm = vecnorm.normalize_obs if isinstance(vecnorm, VecNormalize) else (lambda o: o)
return lambda obs: model.predict(norm(obs.astype(np.float32)), deterministic=True)[0]
def load_model(path):
"""保存したモデル(と、あれば正規化の統計 .pkl)を読み込み、policy(obs) を返す"""
import pickle
from stable_baselines3 import PPO, SAC
path = Path(path)
model = (SAC if path.name.startswith("sac") else PPO).load(path, device="cpu")
vecnorm = None
if path.with_suffix(".pkl").exists():
with open(path.with_suffix(".pkl"), "rb") as f:
vecnorm = pickle.load(f) # 統計だけを使う(環境は不要)
return sb3_policy(model, vecnorm)
def pad(s, width):
"""全角文字を幅 2 として、表示幅が width になるように右側を空白で埋める"""
return s + " " * (width - sum(2 if ord(c) > 0x2E80 else 1 for c in s))
def print_row(name, r):
print(f"{pad(name, 24)} {r['success_rate'] * 100:6.1f}% {r['mean_steps']:12.1f} {r['torque_rate']:18.1f}"
f" {r['timeout_dist'] * 100:17.1f}")
if __name__ == "__main__":
print(f"{pad('方策', 24)} {pad('成功率', 7)} {pad('平均ステップ', 12)} トルク変化率[Nm/s] 時間切れ時の距離[cm]")
print_row("ヤコビ転置法(第0章)", evaluate(scripted_policy))
for path in sys.argv[1:]:
print_row(Path(path).stem, evaluate(load_model(path)))
4.5 学習:train_ppo.py
"""第9章: PPO で Reach タスクを学習する
使い方:
python train_ppo.py # dense 報酬で学習 → models/ppo_dense.zip(+ 正規化の統計 .pkl)
python train_ppo.py --reward sparse # 報酬を変えて学習
python train_ppo.py --no-normalize # 観測の正規化なしで学習(失敗例の再現)
python train_ppo.py --steps 300000 # 学習ステップ数を減らして短時間で試す
"""
import argparse
import time
from pathlib import Path
import numpy as np
import torch
from stable_baselines3 import PPO
from stable_baselines3.common.callbacks import BaseCallback
from stable_baselines3.common.vec_env import VecMonitor, VecNormalize
from evaluate import evaluate, sb3_policy
from sb3_env import SB3ReachEnv
from vec_env import GAMMA, REWARD_MODES
torch.set_num_threads(1) # 小さなネットワークでは、1スレッドのほうが速い(結果もコア数に依存しなくなる)
NUM_ENVS = 64
TOTAL_STEPS = 3_000_000
PPO_KWARGS = dict(
n_steps=64, # 1回の更新で 64 環境 × 64 ステップ = 4096 個の経験を集める
batch_size=1024,
n_epochs=10, # 集めた経験を 10 回使い回して方策を更新する
gamma=GAMMA,
learning_rate=3e-4,
clip_range=0.2, # PPO の「1回で変えすぎない」ための上限
)
class ProgressCallback(BaseCallback):
"""一定間隔で、学習の進み具合(報酬の平均・成功率)を表示する"""
def __init__(self, every=250_000):
super().__init__()
self.every, self.next_print, self.t0 = every, every, time.perf_counter()
def _on_step(self):
if self.num_timesteps >= self.next_print:
self.next_print += self.every
rew = np.mean([ep["r"] for ep in self.model.ep_info_buffer]) if self.model.ep_info_buffer else float("nan")
succ = np.mean(self.model.ep_success_buffer) * 100 if self.model.ep_success_buffer else float("nan")
print(f"{self.num_timesteps:>10,d} steps {time.perf_counter() - self.t0:5.0f} s"
f" エピソード報酬 {rew:8.2f} 成功率 {succ:5.1f}%", flush=True)
return True
def train(reward="dense", total_steps=TOTAL_STEPS, seed=0, num_envs=NUM_ENVS, normalize=True, verbose=True):
"""PPO で学習し、(モデル, 学習に使った環境, 学習にかかった秒数) を返す"""
env = VecMonitor(SB3ReachEnv(num_envs, seed=seed, reward=reward)) # エピソードの報酬・長さを記録する
if normalize:
env = VecNormalize(env, norm_reward=False) # 観測を「平均 0・標準偏差 1」に揃える(統計は学習中に更新)
model = PPO("MlpPolicy", env, seed=seed, device="cpu", verbose=0, **PPO_KWARGS)
t0 = time.perf_counter()
model.learn(total_steps, callback=ProgressCallback() if verbose else None)
return model, env, time.perf_counter() - t0
def save(model, env, path):
"""モデル(.zip)と、観測の正規化の統計(同名の .pkl)を保存する"""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
model.save(path)
if isinstance(env, VecNormalize):
env.save(path.with_suffix(".pkl"))
if __name__ == "__main__":
p = argparse.ArgumentParser()
p.add_argument("--reward", default="dense", choices=REWARD_MODES)
p.add_argument("--steps", type=int, default=TOTAL_STEPS)
p.add_argument("--seed", type=int, default=0)
p.add_argument("--no-normalize", action="store_true")
args = p.parse_args()
model, env, sec = train(args.reward, args.steps, args.seed, normalize=not args.no_normalize)
name = f"ppo_{args.reward}" + ("_nonorm" if args.no_normalize else "")
path = Path("models") / f"{name}.zip"
save(model, env, path)
r = evaluate(sb3_policy(model, env))
print(f"\n学習時間 {sec:.0f} 秒 → {path}")
print(f"評価(未知の 1024 環境 × 10 秒): 成功率 {r['success_rate'] * 100:.1f}%"
f" / 平均ステップ {r['mean_steps']:.1f} / トルク変化率 {r['torque_rate']:.1f} Nm/s")
ポイントは3つです。
- VecNormalize:観測の各次元を、平均 0・標準偏差 1 になるように正規化します。統計は学習中に更新され、評価でも同じ統計を使います(そのために .pkl に保存します)。その効果は、結果の ③ で見ます
- PPO_KWARGS:64 環境 × 64 ステップ = 4096 個の経験を集めるたびに、それを 10 回使い回して方策を更新します。clip_range=0.2 が、2.2 節の「1回の更新で変えすぎない」ための上限です
- torch.set_num_threads(1):方策のネットワークは 64 ユニット × 2 層と小さいため、PyTorch のスレッドを増やすと、スレッド間の調整のコストのほうが大きくなります。筆者の環境では、既定のスレッド数(筆者の PC では 8)より 1 スレッドのほうが約 2.5 倍速くなりました(20 万ステップの学習で 21.2 秒 → 8.6 秒)。SB3 のドキュメントにも、CNN を使わない PPO は主に CPU で動かすことを想定している、とあります[^sb3-ppo]
python train_ppo.py
python train_ppo.py --no-normalize
python evaluate.py models/ppo_dense.zip models/ppo_dense_nonorm.zip
筆者の環境(Windows 11、CPU のみ)での実行結果です。
250,048 steps 31 s エピソード報酬 -41.20 成功率 0.0%
500,032 steps 59 s エピソード報酬 -28.70 成功率 1.0%
750,016 steps 83 s エピソード報酬 -18.18 成功率 4.0%
1,000,000 steps 108 s エピソード報酬 -12.39 成功率 48.0%
1,250,048 steps 130 s エピソード報酬 -9.52 成功率 76.0%
1,500,032 steps 154 s エピソード報酬 -8.31 成功率 96.0%
1,750,016 steps 177 s エピソード報酬 -8.38 成功率 95.0%
2,000,000 steps 200 s エピソード報酬 -7.49 成功率 94.0%
2,250,048 steps 222 s エピソード報酬 -7.16 成功率 96.0%
2,500,032 steps 240 s エピソード報酬 -7.14 成功率 99.0%
2,750,016 steps 262 s エピソード報酬 -7.21 成功率 99.0%
3,000,000 steps 283 s エピソード報酬 -8.86 成功率 99.0%
学習時間 283 秒 → models\ppo_dense.zip
評価(未知の 1024 環境 × 10 秒): 成功率 94.3% / 平均ステップ 37.2 / トルク変化率 13.9 Nm/s
方策 成功率 平均ステップ トルク変化率[Nm/s] 時間切れ時の距離[cm]
ヤコビ転置法(第0章) 86.8% 86.6 32.7 2.7
ppo_dense 94.3% 37.2 13.9 1.3
ppo_dense_nonorm 45.7% 48.5 5.3 3.2
① 学習は、ある時点から急に進む
学習曲線(エピソード報酬と成功率)を見ると、最初の 75 万ステップほどは、成功率が数 % 以下のままです。この間、方策は「目標の方向へ腕を振る」ことは覚えていきますが(エピソード報酬は −41 から −18 へ改善)、1 cm 以内に止まるところまでは届きません。100 万ステップを過ぎたあたりで成功率が一気に上がり、150 万ステップで 90% を超えました。
300 万ステップは、シミュレーション時間に直すと 300 万 × 0.02 秒 = 約 17 時間分 の試行錯誤です。CPU 1コアで約5分でした。第7章で見た「並列シミュレーションで、実時間よりはるかに速く経験を集める」ことが、そのまま学習の速さになっています。
② 手書きの制御器を上回った
| 方策 | 成功率 | 平均ステップ | トルク変化率 [Nm/s] |
|---|---|---|---|
| ヤコビ転置法(第0章) | 86.8% | 86.6 | 32.7 |
| PPO(観測の正規化あり) | 94.3% | 37.2 | 13.9 |
学習した方策は、ヤコビ転置法より 成功率が高く、2倍以上速く目標に到達し、トルクの変化もなめらか でした。
ただし、これを「強化学習は手書きの制御器より優れている」と読むのは早計です。
- ヤコビ転置法のゲイン($K_p = 100$、$K_d = 1.5$)は、第0章で決めた値のままで、このタスク向けには調整していません。第7章の演習2のように、4096 環境でゲインを探索すれば、差はかなり縮まるはずです
- 一方で、ヤコビ転置法を書くには ロボットの運動学(ヤコビアン)を知っている 必要がありました。PPO は、運動方程式もヤコビアンも知らず、観測と報酬だけ からこの動きを見つけています。ここが強化学習の本当の価値です
③ 観測の正規化がなければ、半分も成功しない
--no-normalize で学習すると、同じ 300 万ステップでも成功率は 45.7% にとどまりました。さらに、シードを変えると結果が大きく変わります。
| | シード 0 | シード 1 |
|---|---|---|
| 観測の正規化あり | 94.3% | 96.2% |
| 観測の正規化なし | 45.7% | 28.7% |
(シード 1 は python train_ppo.py --seed 1 で再現できます)
観測の 10 次元は、スケールがばらばらです。cos・sin は ±1 の範囲ですが、関節速度は数 rad/s まで振れ、目標までのずれは、成功の直前には 0.01 m 以下 になります。評価の表の「時間切れ時の距離」を見ると、正規化なしの方策は、時間切れになったエピソードで 目標まで 3.2 cm(中央値)を残して 終わっていました(正規化ありでは 1.3 cm)。目標の手前までは来ているのに、最後の数 cm を詰めきれていないのです。正規化しないと、ネットワークにとって「最後の数 cm」の違いは入力の中のごく小さな変化でしかなく、そこでの細かな制御を学びにくいのだと考えられます。
SB3 のドキュメントの「RL Tips and Tricks」には、「エージェントへの入力は、常に正規化すべき(PPO/A2C なら VecNormalize などを使う)」 と書かれています[^sb3-tips]。強化学習では、アルゴリズムの選択よりも、こうした 地味な前処理 が結果を大きく左右することがよくあります。
4.6 報酬設計の実験:reward_experiments.py
報酬だけを変えて、ほかの条件(アルゴリズム、ステップ数、シード、正規化)をすべてそろえて学習します。
"""第9章: 報酬設計の実験 ― 報酬だけを変えて、同じ条件で PPO を学習・評価する
使い方:
python reward_experiments.py # 5種類の報酬 × 300万ステップ(筆者の環境で合計 10〜20 分)
python reward_experiments.py --steps 1000000 # 短時間で試す
"""
import argparse
from evaluate import evaluate, pad, sb3_policy
from train_ppo import TOTAL_STEPS, train
from vec_env import REWARD_MODES
if __name__ == "__main__":
p = argparse.ArgumentParser()
p.add_argument("--steps", type=int, default=TOTAL_STEPS)
args = p.parse_args()
rows = []
for mode in REWARD_MODES:
model, env, sec = train(mode, args.steps, verbose=False)
# 成功率は共通の物差し。報酬の合計は「その報酬関数で測った値」なので、モードの間では比べられない
r = evaluate(sb3_policy(model, env), reward=mode)
rows.append((mode, r, sec))
print(f"{mode:18s} 学習 {sec:4.0f} 秒 / 成功率 {r['success_rate'] * 100:5.1f}%", flush=True)
print(f"\n{pad('報酬', 18)} {pad('成功率', 7)} {pad('平均ステップ', 12)} "
f"{pad('トルク変化率', 12)} {pad('時間切れ時の距離[cm]', 20)} 1エピソードの報酬")
for mode, r, sec in rows:
print(f"{mode:18s} {r['success_rate'] * 100:6.1f}% {r['mean_steps']:12.1f} "
f"{r['torque_rate']:12.1f} {r['timeout_dist'] * 100:20.1f} {r['mean_return']:17.2f}")
python reward_experiments.py
dense 学習 188 秒 / 成功率 94.3%
no_action_penalty 学習 146 秒 / 成功率 95.3%
sparse 学習 112 秒 / 成功率 0.4%
progress 学習 114 秒 / 成功率 0.5%
potential 学習 133 秒 / 成功率 95.8%
報酬 成功率 平均ステップ トルク変化率 時間切れ時の距離[cm] 1エピソードの報酬
dense 94.3% 37.2 13.9 1.3 -7.32
no_action_penalty 95.3% 34.2 17.6 1.3 -6.55
sparse 0.4% 75.8 8.1 34.2 0.00
progress 0.5% 87.9 361.1 14.6 127.95
potential 95.8% 45.3 16.9 1.5 69.04
「1エピソードの報酬」は、それぞれの報酬関数で測った値なので、行の間では比べられません。共通の物差しは成功率です。
① sparse 報酬では、ほとんど学べない
sparse(成功したときだけ 1)の成功率は 0.4% でした。学習の初期の方策は、ランダムに近い動きをします。ランダムな動きで「1 cm 以内に入って、しかも止まる」ことはまず起きないので、報酬は最後までほとんど 0 のままで、学習の手がかりがありませんでした。時間切れの時点でも、目標まで 34.2 cm(中央値)残っており、目標に向かうことすら覚えていません。3.1 節の 探索の問題 です。同じ「成功してほしい」という意図でも、dense 報酬(成功率 94.3%)とはまったく違う結果になります。
② 行動のペナルティを外すと、少し荒くなる
no_action_penalty は、成功率では dense とほぼ同じ(95.3%)で、トルク変化率が 13.9 → 17.6 Nm/s と約 3 割大きくなりました。動きが荒くなる方向には変わりましたが、筆者の予想よりは小さな差でした。理由は2つ考えられます。1つは、係数 0.01 が小さく、距離の項に比べて弱かったこと。もう1つは、3.2 節で書いたように、このペナルティは トルクの「大きさ」 に対するもので、トルクの「変化」 を直接は罰していないことです。測りたいもの(変化率)と、罰しているもの(大きさ)がずれていれば、効き目が小さいのは当然です。報酬設計では、本当に求めている性質を、そのまま報酬に書けているか を確かめることが大切です(演習1)。
③ 抜け穴のある報酬は、必ず見つけられる
progress(近づいたら +1)は、1エピソードあたり約 128 の報酬を稼ぎながら、成功率は 0.5% でした。そして、トルク変化率は 361 Nm/s、dense の約 26 倍です。
何が起きたのでしょうか。この報酬は「近づいたら +1」で、「遠ざかっても罰はない」ので、少し遠ざかってから、また近づけば、何度でも +1 をもらえます。しかも、成功するとエピソードが終わって、それ以上稼げなくなります。報酬 約 128 は、3秒間(150 ステップ)のうち 8 割以上のステップで「近づいた」と判定されたことを意味します。それなのに、時間切れの時点でも目標まで 14.6 cm(中央値) も残っていました。エージェントは、目標に向かってわずかずつ近づき、ときどき少し戻ってはまた近づく、という細かな往復を、トルクを激しく切り替えながら続け、あえて目標に着かない 方策を見つけたのです。「コースを完走せず、同じ場所を回って得点を稼ぎ続ける」3.3 節の CoastRunners のボートと、同じ構図です。
ヒントの形を、3.4 節のポテンシャル型に変えるだけで(potential)、成功率は 95.8% に戻りました。行って戻ってくると、もらった分だけ引かれるので、往復して稼ぐことができなくなったからです。
報酬は仕様書であり、エージェントは仕様書の抜け穴を、人間よりも先に、確実に見つけます。 強化学習で一番時間がかかるのは、アルゴリズムの実装ではなく、この「抜け穴のない仕様」を書くことです。
4.7 演習
1. vec_env.py の dense 報酬で、行動のペナルティの係数 0.01 を 0.1、1.0 と大きくすると、成功率とトルク変化率はどう変わるでしょうか。次に、ペナルティを前のステップとの差 $\sum (a_t - a_{t-1})^2$ に変えて(前のステップの行動を self.prev_actions などに保存します)、トルク変化率の下がり方を比べてみましょう。
2. train_ppo.py の PPO_KWARGS で、clip_range を 0.2 から 1.0 に広げたり、n_epochs を 30 に増やしたりして、学習曲線が不安定になるか確かめてみましょう。2.2 節の「変えすぎない」工夫の効果が見えます。
3. Stable-Baselines3 の SAC で同じタスクを学習し、同じ環境ステップ数での成功率を PPO と比べてみましょう(ヒント:SAC は環境数を少なく、例えば 4 にします。evaluate.py の load_model は、ファイル名が sac で始まるモデルを SAC として読み込みます)。
4. potential 報酬の SUCCESS_BONUS を 0 にすると、何が起きるでしょうか。ポテンシャル型のヒントは「最適な方策を変えない」ので、本来の報酬(成功)がなくなると、何が最適になるかを考えてみましょう。
5. (発展)_reset_idx で、リンクの質量や関節の粘性を環境ごとにランダムに変えて学習し、ランダム化せずに学習した方策と、質量を変えた環境での成功率を比べてみましょう。第18章の Sim2Real の予習になります。
---
5. 産業界における強化学習
5.1 歩行:強化学習が最も成功している分野
強化学習が実用レベルで最も成功しているのは、脚ロボットの歩行 です。ETH Zurich の Rudin らは 2021年に、GPU 上で 数千台のロボットを並列にシミュレーション し、4脚ロボット ANYmal の平地の歩行を 4分以内、不整地の歩行を約 20 分で学習させ、実機に移せることを示しました(ワークステーション用の GPU 1枚)[^rudin]。この研究のコードは legged_gym として公開され、そこから生まれた PPO のライブラリ RSL-RL は、現在も Isaac Lab などで使われています[^rslrl]。
脚ロボットの研究を整理したサーベイでも、最適制御と強化学習が「脚ロボットを制御する2つの支配的なアプローチ」とされ、特に PPO がよく使われていると述べられています[^locomotion-survey]。歩行は、「転ばずに、指定した速度で進む」という報酬が比較的書きやすく、シミュレーションで大量に試行できるという、強化学習に向いた条件がそろっています。
5.2 SayCan の「アフォーダンス」は価値関数だった
[第1章](./01_ToolCallingからActionへ.md)で紹介した SayCan では、LLM が提案したスキルの「実行可能性(アフォーダンス)」を、強化学習で学習した価値関数 が評価していました[^saycan]。成功すれば 1、失敗すれば 0 という sparse な報酬で、「今の状態から、このスキルが成功する確率」を TD 学習で見積もっています。
興味深いのは、実際にロボットを動かす方策は、主に 人間のデモからの模倣学習(BC) で作られていたことです。論文では、BC の方策のほうが成功率は高いが、RL の方策が与える価値関数が(LLM と組み合わせるうえで)決定的に重要だった、と述べられています。「動かすのは模倣学習、評価するのは強化学習」 という役割分担です。
5.3 VLA を強化学習で鍛える
2025年からは、第12章で扱う VLA(Vision-Language-Action)モデル を、強化学習で追加学習する研究が急増しています。
- π*0.6(RECAP):Physical Intelligence が 2025年11月に発表。VLA を、ロボット自身の経験と人間による修正から強化学習で改善し、難しいタスクではスループットが2倍以上、失敗率がおよそ半分になったと報告しています[^pistar]
- SimpleVLA-RL:成功・失敗だけの報酬で、OpenVLA-OFT を強化学習で追加学習し、シミュレーションのベンチマーク(LIBERO)で最高水準の成績を報告しています[^simplevla]
- ConRFT:模倣学習で事前学習した方策を、実機で 45〜90 分の強化学習で追加学習し、8つの実タスクで平均 96.3% の成功率を報告しています[^conrft]
これらに共通するのは、まず人間のデモで「だいたいできる」方策を作り、そこから強化学習で磨く という順番です。ゼロから強化学習だけで物体操作を学ぶのではありません。
5.4 物体操作の主役は、いまは模倣学習
本章の Reach は、報酬が「距離」で素直に書ける、強化学習にとって易しいタスクでした。しかし、「洗濯物をたたむ」「箱を組み立てる」といった物体操作では、何をもって「うまくたためた」とするかを、報酬として書くこと自体が難しい のです。4.6 節で見たように、書き方を少し間違えるだけで、エージェントは抜け穴を突いてきます。
そのため、筆者の見るところ、現在の物体操作の研究・開発では、人間の操作のデモから学ぶ模倣学習 が中心で、強化学習は5.3 節のような「仕上げ」に使われることが多くなっています。実際、VLA の多くは人間の遠隔操作のデータを模倣して学習されています[^vlarl]。
---
6. まとめ
- 強化学習 は、正解を教えず、報酬だけ を手がかりに試行錯誤で方策を学ぶ。問題は MDP(状態・行動・遷移・報酬・割引率)として整理でき、第7章の並列環境はそのまま MDP の実装になっていた
- PPO は「よかった行動を、一度に変えすぎない範囲で増やす」オンポリシーのアルゴリズムで、並列シミュレーションと相性がよく、ロボット学習の標準。SAC は経験を貯めて使い回すオフポリシーのアルゴリズムで、サンプル効率が高く、実機での学習に向く
- 実践では、Stable-Baselines3 の PPO で、CPU 1コア・約5分(約 17 時間分の試行錯誤)で Reach を学習し、成功率 94.3% と、未調整のヤコビ転置法(86.8%)を上回った。ただし、強化学習の本当の価値は「上回ったこと」ではなく、運動学を知らずに、観測と報酬だけでここまで来たこと にある
- 観測の正規化 を忘れると、成功率は 45.7% に落ち、シードによって大きくばらついた。強化学習では、地味な前処理が結果を大きく左右する
- 報酬は仕様書。sparse 報酬では探索がうまくいかず(0.4%)、抜け穴のある報酬では、エージェントは 細かな往復で「近づいた」判定を稼ぎ続け、あえて目標に着かない 方策を見つけた(報酬ハッキング)。ポテンシャル型 の整形にすると、この抜け穴は消えた
- 産業界では、強化学習は 歩行 で大きく成功している。物体操作では、報酬を書くこと自体が難しいため 模倣学習が中心 で、強化学習は VLA の「仕上げ」や、SayCan のような価値の評価に使われている
次回予告
第10章「模倣学習(IL)」 では、報酬を書く代わりに、人間のデモをお手本にして 方策を学びます。最も素朴な Behavior Cloning から始め、「デモにない状況に入ると、誤差が雪だるま式に増える」という模倣学習ならではの落とし穴と、それを乗り越える ACT と Diffusion Policy を学びます。[第5章](./05_制御と軌道生成.md)で予告した「アクションチャンク」が、いよいよ登場します。
---
参考文献
[^ppo]: J. Schulman, F. Wolski, P. Dhariwal, A. Radford, O. Klimov, "Proximal Policy Optimization Algorithms", arXiv:1707.06347, 2017. arxiv.org
[^sac]: T. Haarnoja, A. Zhou, P. Abbeel, S. Levine, "Soft Actor-Critic: Off-Policy Maximum Entropy Deep Reinforcement Learning with a Stochastic Actor", ICML 2018, arXiv:1801.01290. arxiv.org
[^sac-blog]: T. Haarnoja et al., "Soft Actor Critic—Deep Reinforcement Learning with Real-World Robots", BAIR Blog, 2018-12-14. bair.berkeley.edu
[^her]: M. Andrychowicz et al., "Hindsight Experience Replay", NeurIPS 2017, arXiv:1707.01495. arxiv.org
[^specgaming]: V. Krakovna et al., "Specification gaming: the flip side of AI ingenuity", Google DeepMind Blog, 2020-04-21. deepmind.google
[^coastrunners]: J. Clark, D. Amodei, "Faulty Reward Functions in the Wild", OpenAI, 2016-12-21. openai.com
[^ng]: A. Y. Ng, D. Harada, S. Russell, "Policy Invariance Under Reward Transformations: Theory and Application to Reward Shaping", ICML 1999, pp. 278–287. mlanthology.org
[^sb3-paper]: A. Raffin et al., "Stable-Baselines3: Reliable Reinforcement Learning Implementations", Journal of Machine Learning Research, 22(268):1–8, 2021. jmlr.org
[^sb3-pypi]: PyPI, "stable-baselines3"(2.9.0、2026-06-15). pypi.org
[^sb3-ppo]: Stable-Baselines3 Documentation, "PPO". stable-baselines3.readthedocs.io
[^sb3-tips]: Stable-Baselines3 Documentation, "Reinforcement Learning Tips and Tricks". stable-baselines3.readthedocs.io
[^rudin]: N. Rudin, D. Hoeller, P. Reist, M. Hutter, "Learning to Walk in Minutes Using Massively Parallel Deep Reinforcement Learning", CoRL 2021, arXiv:2109.11978. arxiv.org
[^rslrl]: leggedrobotics, "rsl_rl", GitHub. leggedrobotics/rsl_rl
[^locomotion-survey]: S. Ha et al., "Learning-based legged locomotion; state of the art and future perspectives", arXiv:2406.01152, 2024. arxiv.org
[^saycan]: M. Ahn et al., "Do As I Can, Not As I Say: Grounding Language in Robotic Affordances", CoRL 2022, arXiv:2204.01691. arxiv.org
[^pistar]: Physical Intelligence, "π*0.6: a VLA That Learns From Experience", arXiv:2511.14759, 2025. pi.website
[^simplevla]: H. Li et al., "SimpleVLA-RL: Scaling VLA Training via Reinforcement Learning", arXiv:2509.09674, 2025. arxiv.org
[^conrft]: Y. Chen et al., "ConRFT: A Reinforced Fine-tuning Method for VLA Models via Consistency Policy", RSS 2025, arXiv:2502.05450. arxiv.org
[^vlarl]: G. Lu et al., "VLA-RL: Towards Masterful and General Robotic Manipulation with Scalable Reinforcement Learning", arXiv:2505.18719, 2025. arxiv.org
Mentions
【フィジカルAI入門 #09】強化学習(RL) ― 報酬だけを手がかりに、ロボットは自分で動き方を学べるか はじめに 第8章までで、Part 3「シミュレーション」が終わりました。ロボットを動かすための数学(第3〜5章)と、試行錯誤の場であるシミュレータ(第6〜8章)がそろったことになります。 ここまで、ロボットの「考える部分」、つまり第0章の think() は、すべて 人間が数式で書いて きました。ヤコビ転置法、逆運動学、PID 制御。どれも「ロボットの形と物理がわかっていれば、正しい動かし方を計算できる」という前提に立っています。 本章から Part 4「ロボット学習」に入ります。最初に扱うのは 強化学習(Reinforcement Learning、RL) です。強化学習では、正しい動かし方を教えません。「うまくいったら点数を与える」というルール(報酬)だけを決め、あとはロボットが試行錯誤で動き方を見つけます。 本章のゴールは次の4つです。 - 強化学習の考え方と、マルコフ決定過程(MDP) の5つの要素を、第7章の並列環境のコードと対応づけて理解する - PPO と SAC という代表的なアルゴリズムを、数式の導出なしに直感で理解し、使い分けを知る - 強化学習で最も難しいと言われる 報酬設計 の勘所(dense と sparse、行動のペナルティ、報酬ハッキング)を、実験で確かめる - 第7章の Reach 環境を Stable-Baselines3 の PPO で学習し、第0章の手書きの制御器と比べる :::note info 前提と実行環境 - 本章のコードは CPU だけで動きます(GPU は不要)。筆者の環境では、1回の学習が5分前後で終わります - 強化学習ライブラリ Stable-Baselines3、Gymnasium、PyTorch(CPU 版で十分)が必要です。インストール方法は 4.1 節を参照してください - 第7章の vec_env.py(N 環境を一括で計算する Reach 環境)を土台にします。第7章を読んでいると理解しやすくなります ::: --- 1. 強化学習とは 1.1 正解を教えずに、点数だけを教える ソフトウェアエンジニアに身近な例で考えてみます。Web サイトのボタンの色を決めるとき、正解の色を知っている人はいません。そこで A/B テスト をして、クリック率の高いほうを選びます。さらに、「よく効いた案を多めに出しつつ、ほかの案もときどき試す」ように自動化したものが、推薦システムなどで使われる 多腕バンディット です。 強化学習は、この考え方を 「一連の行動」 に広げたものです。 | | A/B テスト・バンディット | 強化学習 | |---|---|---| | 1回の試行 | 1つの案を見せる | 状態を見て行動する、を何十〜何百回も繰り返す | | 評価 | クリックしたか | 各ステップの報酬の合計 | | 難しさ | どの案がよいか | どの時点のどの行動が、最終的な結果に効いたのか(信用割り当て問題) | ロボットの Reach タスクで言えば、「手先が目標に届いた」という結果は、それまでの 50 回分のトルクの出し方すべての結果です。どのトルクがよかったのかを、試行錯誤の結果から逆算していくのが強化学習の中心的な難しさです。 1.2 マルコフ決定過程(MDP) 強化学習では、問題を マルコフ決定過程(MDP) という形に整理します。難しそうな名前ですが、中身は5つの要素だけです。第7章の vec_env.py は、実はこの5つをそのまま実装していました。 | 要素 | 意味 | vec_env.py での実装 | |---|---|---| | 状態 $s$ | 今の状況 | _get_observations():関節角(cos, sin)、関節速度、目標位置、目標までのずれ(10次元) | | 行動 $a$ | エージェントが選べること | step(actions) の引数:2つの関節トルク($[-1, 1]$ に正規化) | | 遷移 $P(s' \mid s, a)$ | 行動の結果、次にどうなるか | dynamics():運動方程式を 10 ステップ積分 | | 報酬 $r$ | その行動がどれだけよかったか | _get_rewards():目標との距離にマイナスを付けたもの、など | | 割引率 $\gamma$ | 将来の報酬をどれだけ重視するか | 学習側の設定(本章では $\gamma = 0.99$) | 「マルコフ」とは、次に何が起きるかは、今の状態と行動だけで決まる(過去の履歴は要らない)という性質です。観測に関節速度が入っているのはこのためです。角度だけでは、腕が今どちら向きに動いているのかわからず、次の状態を予測できません。 1.3 方策と収益 エージェントが学ぶのは 方策(policy) $\pi(a \mid s)$、つまり「状態を見て行動を決める関数」です。第0章の think() そのものです。本章では、方策を小さなニューラルネットワーク(全結合層 2 層)で表します。 学習の目標は、1エピソードを通した報酬の合計(収益)を最大にすることです。 $$ G = r_0 + \gamma r_1 + \gamma^2 r_2 + \cdots $$ 割引率 $\gamma$ は「遠い将来の報酬ほど割り引く」ための係数で、$\gamma = 0.99$ なら、だいたい $1/(1-\gamma) = 100$ ステップ先までを見通すことになります。本章の環境では方策が 50Hz で動くので、約2秒先です。 収益の期待値を状態ごとに見積もったものを 価値関数 $V(s)$ と呼びます。「この状態からなら、この先どれくらいの報酬が期待できるか」を表す関数で、強化学習のアルゴリズムの多くが、方策と一緒にこれを学習します。[第1章](./01_ToolCallingからActionへ.md)で紹介した SayCan の「アフォーダンス」も、実はこの価値関数でした(5.2 節)。 --- 2. 方策勾配から PPO へ 2.1 方策勾配:うまくいった行動を、少しだけ多めに ニューラルネットワークの方策を、どうやって改善すればよいのでしょうか。教師あり学習なら「正解の行動」との誤差を小さくすればよいのですが、強化学習には正解がありません。 方策勾配法 の考え方は、次のとおりです。 1. 今の方策で、少しランダムさを加えながら行動してみる 2. 平均より結果がよかった行動 は、次から選ばれやすくなるように、ネットワークの重みを少し動かす 3. 平均より悪かった行動 は、選ばれにくくする 「平均よりどれだけよかったか」を アドバンテージ と呼びます。平均(ベースライン)の見積もりに、1.3 節の価値関数を使います。方策(Actor、行動する役)と価値関数(Critic、評価する役)を同時に学習するので、このような構成を Actor-Critic と呼びます。 2.2 PPO:一度に変えすぎない 素朴な方策勾配法には、1回の更新で方策が大きく変わりすぎて、学習が崩壊しやすい という弱点があります。たまたまうまくいった行動に飛びつきすぎて、それまでに覚えたことを壊してしまうのです。 PPO(Proximal Policy Optimization) は、2017年に OpenAI が発表したアルゴリズムで、この問題を 「新しい方策と古い方策で、行動の選ばれやすさの比が $[1-\epsilon, 1+\epsilon]$ の範囲を超えたら、それ以上は得をしない」 というクリッピングで抑えます[^ppo]。$\epsilon$ は 0.2 がよく使われ、「1回の更新で、ある行動の確率を 2 割を超えて変えても、得をしない」というイメージです。 この制限のおかげで、同じデータを 何回も使い回して 更新しても安全になります(本章の設定では 10 回)。実装が簡単で、ハイパーパラメータにも比較的鈍感なため、PPO は現在も ロボット学習の標準的なアルゴリズム です。第7章で触れた Isaac Lab の RSL-RL も、PPO を中心にしたライブラリです。 ソフトウェア開発に例えるなら、カナリアリリース に近い考え方です。新しいバージョン(方策)がよさそうでも、一気に全部を切り替えず、変化の幅を制限しながら少しずつ移行します。 2.3 SAC:過去の経験も使い回す PPO は オンポリシー のアルゴリズムで、「今の方策で集めたデータ」しか学習に使えません。方策を更新したら、それまでのデータは捨てて、また集め直します。シミュレータで大量のデータを安く集められるなら問題ありませんが、実機ではデータ集めが高くつきます。 SAC(Soft Actor-Critic) は、2018年に UC Berkeley のグループが発表した オフポリシー のアルゴリズムです[^sac]。過去の経験をすべて リプレイバッファ に貯めておき、何度でも学習に使います。また、報酬に加えて 行動のランダムさ(エントロピー)も大きく保つ ことを目標にするのが特徴で、「タスクをこなしつつ、できるだけいろいろな動き方を試す」ように学習します。 SAC の論文の著者らは、4脚ロボット Minitaur の歩行や、ブロックの積み上げを、実機の上で約2時間 の試行錯誤で学習させています[^sac-blog]。 2.4 どちらを使うか | | PPO | SAC | |---|---|---| | データの使い方 | オンポリシー(集めたら捨てる) | オフポリシー(貯めて使い回す) | | サンプル効率 | 低い(大量の試行が必要) | 高い | | 並列化との相性 | よい(数千環境で一気に集めて、まとめて更新する) | 比較的悪い(集めた経験 1 ステップごとに、ネットワークを更新するのが基本) | | 向いている場面 | GPU 並列シミュレーション、歩行の学習 | 実機での学習、シミュレーションが重い場合 | 第7章で見たように、現在のロボット学習では シミュレータで数千環境を並列に動かす のが標準です。データが安く大量に手に入るので、サンプル効率より安定性と速さを重視して、PPO が選ばれることが多くなっています。本章でも PPO を使います。 --- 3. 報酬設計 強化学習では、報酬が「仕様書」 です。エージェントは、仕様書に書かれたことを、書かれたとおりに、徹底的に最適化します。書かれていないことは一切考慮しません。ここに、強化学習で最も難しいと言われる 報酬設計 の問題があります。 3.1 dense と sparse Reach タスクの報酬は、大きく2通りに書けます。 | 種類 | 例 | 長所 | 短所 | |---|---|---|---| | sparse(疎な報酬) | 目標に届いたら 1、それ以外は 0 | 仕様として明快。「何をしてほしいか」がそのまま書ける | 偶然届くまで何も学べない | | dense(密な報酬) | 毎ステップ「−(目標までの距離)」 | 少し近づくだけで改善がわかり、学習が速い | 「距離を縮める」ことが本当の目的と一致している保証はない | sparse 報酬では、ランダムな動きで偶然目標に届くまで、報酬はずっと 0 です。報酬が 0 のままでは、どの行動がよかったのかの手がかりがありません。これを 探索の問題 と呼び、疎で二値の報酬からどう学ぶかは、それ自体が研究テーマになっています[^her]。 3.2 書かなかったことは守られない dense 報酬で「距離を縮める」ことだけを報酬にすると、エージェントにとって、動きのなめらかさやモーターへの負担は、どうでもよいこと になります。距離さえ縮まれば、トルクを急に切り替えようが、見た目がぎこちなかろうが、報酬には関係ないからです。 そこで、人間が暗黙に期待していた仕様を、報酬として明文化します。第7章の報酬にある -0.01 * Σa² は、行動(トルク)の大きさ へのペナルティで、「必要以上の力を出さない」ことを求めます。ただし、これは「急に切り替えない」こと、つまりなめらかさを直接求めるものではありません。なめらかさまで求めたいなら、前のステップとの差 $\sum (a_t - a_{t-1})^2$ にペナルティを付けます。脚ロボットの強化学習では、このような項(action rate)がよく使われます。どれくらい効くかは、4.6 節の実験で確かめます。 3.3 報酬ハッキング 報酬の書き方に抜け穴があると、エージェントはそれを見つけて悪用します。これを 報酬ハッキング(または 仕様ゲーミング)と呼びます。DeepMind は仕様ゲーミングを「目的の文字どおりの仕様は満たしているのに、意図した結果は達成していない振る舞い」と定義し、多数の事例を集めています[^specgaming]。 有名な例が、OpenAI が 2016年に紹介したボートレースゲーム CoastRunners です。コース上のアイテムを取ると得点が入る報酬で学習させたところ、エージェントは レースを完走せず、同じ場所をぐるぐる回ってアイテムを取り続ける 方法を見つけ、完走するよりも高い得点を出しました[^coastrunners]。 3.4 抜け穴のない「整形」:ポテンシャル型の報酬 学習を速くするために、sparse な本来の報酬に「ヒント」を足すことを 報酬整形(reward shaping) と呼びます。しかし、3.3 節のように、足したヒントそのものが抜け穴になることがあります。 Ng らは 1999年に、ヒントを「ポテンシャルの差」の形で足せば、最適な方策は変わらない ことを示しました[^ng]。 $$ F(s, s') = \gamma \Phi(s') - \Phi(s) $$ $\Phi(s)$ は「状態のよさ」を表す任意の関数で、Reach なら「−(目標までの距離)」が自然です。近づけば $F$ はプラス、遠ざかればマイナスになります。ポイントは、行って戻ってくると、もらった分とほぼ同じだけ引かれる ことです。ぐるぐる回って稼ぐことができないので、ヒントが抜け穴になりません。 4.6 節では、この「抜け穴のあるヒント」と「ポテンシャル型のヒント」を、実際に学習させて比べます。 --- 4. 実践:第7章の Reach 環境を PPO で学習する 4.1 準備とファイル構成 強化学習の実装には、Stable-Baselines3(SB3) を使います。PyTorch で書かれた、PPO・SAC などの代表的なアルゴリズムの信頼性の高い実装を集めたライブラリです[^sb3-paper]。執筆時点の最新版は 2.9.0 です[^sb3-pypi]。 # uv の場合 uv sync --extra rl # pip の場合(PyTorch は CPU 版を指定すると、ダウンロードが小さく済む) # 先に CPU 版の PyTorch を入れる(順番が逆だと、Linux では GPU 版の大きなパッケージが入る) pip install torch --index-url https://download.pytorch.org/whl/cpu pip install stable-baselines3 gymnasium ch09_reinforcement_learning/ ├── vec_env.py # 第7章の Reach 並列環境(報酬を切り替えられるように拡張) ├── sb3_env.py # vec_env を Stable-Baselines3 の VecEnv として使うアダプタ ├── evaluate.py # 方策の評価(成功率・平均ステップ数・トルク変化率) ├── train_ppo.py # PPO で学習する └── reward_experiments.py # 報酬を5通りに変えて、学習結果を比べる タスクは第7章と同じです。平面2関節アームの手先を、ランダムな目標位置へ動かします。目標から 1 cm 以内に入り、関節速度がほぼ 0(0.2 rad/s 未満)になったら成功 で、3秒以内に成功しなければ時間切れです。手先を「目標の近くで止める」ところまでが求められる、意外と厳しい条件です。 4.2 環境:vec_env.py 第7章の vec_env.py をコピーし、次の3点だけを変えました。運動方程式・観測・成功判定は第7章と同じです。 1. 報酬を reward 引数で切り替えられる ようにした(4.6 節の実験用)。"dense" が第7章と同じ報酬 2. step() が、Gymnasium の流儀に合わせて 成功による終了(terminated)と時間切れ(truncated)を分けて 返すようにした。時間切れは「失敗」ではなく「途中で打ち切った」だけなので、学習アルゴリズムはこの2つを区別して扱います 3. 終わった環境について、リセットする前の観測(final_obs) も返すようにし、step() が返す観測はリセット後のものにした 3つ目は、実は第7章のコードの小さな不具合の修正でもあります。第7章の step() は、エピソードが終わった環境についても リセット前の観測 を返していたため、リセット直後の1ステップだけ、方策が前のエピソードの観測を見て行動していました。第7章の目的(スループットの比較)には影響しませんが、強化学習では「どの観測でどの行動をしたか」を正確に記録する必要があるので、ここで直しています。この修正と、評価の条件(4.4 節)の違いにより、ヤコビ転置法の成功率は、第7章の 87.5% とはわずかに異なる 86.8% になります。 """第9章: 強化学習で使う、2関節アームの Reach 並列環境 第7章の vec_env.py をコピーし、次の点だけを変えている(動力学・観測・成功判定は同じ)。 - 報酬関数を reward 引数で切り替えられるようにした(報酬設計の実験用) - step() が Gymnasium と同じく terminated(成功)と truncated(時間切れ)を分けて返す - 自動リセットの前の観測 final_obs も返す(学習ライブラリが最後の状態の価値を計算するため) """ import numpy as np L1, L2 = 0.30, 0.25 # リンク長 [m] M1, M2 = 1.0, 0.5 # リンク先端の質量 [kg](質点モデル) DAMP = 0.5 # 関節の粘性 DT, DECIMATION = 0.002, 10 # 物理 500Hz、方策 50Hz(物理10ステップごとに行動を1回決める) EPISODE_STEPS = 150 # 1エピソード = 3秒 TAU_MAX = 5.0 REWARD_MODES = ("dense", "no_action_penalty", "sparse", "progress", "potential") GAMMA = 0.99 # 割引率(potential の整形に使う。PPO の gamma と揃える) PHI_SCALE = 0.01 # potential のスケール [m](1cm 近づくと +1) SUCCESS_BONUS = 10.0 # potential で成功したときのボーナス def dynamics(q, qd, tau): """平面2関節アームの運動方程式 M(q) q̈ + c(q, q̇) + D q̇ = τ を一括で解く(形状 (N, 2))""" c2, s2 = np.cos(q[:, 1]), np.sin(q[:, 1]) m11 = (M1 + M2) * L1**2 + M2 * L2**2 + 2 * M2 * L1 * L2 * c2 m12 = M2 * L2**2 + M2 * L1 * L2 * c2 m22 = np.full_like(m11, M2 * L2**2) h = M2 * L1 * L2 * s2 c = np.stack([-h * (2 * qd[:, 0] * qd[:, 1] + qd[:, 1] ** 2), h * qd[:, 0] ** 2], axis=1) rhs = tau - c - DAMP * qd det = m11 * m22 - m12**2 # 2x2 の逆行列を解析的に qdd = np.stack([(m22 * rhs[:, 0] - m12 * rhs[:, 1]) / det, (-m12 * rhs[:, 0] + m11 * rhs[:, 1]) / det], axis=1) return qdd def fk(q): """手先位置 (N, 2)""" return np.stack([L1 * np.cos(q[:, 0]) + L2 * np.cos(q[:, 0] + q[:, 1]), L1 * np.sin(q[:, 0]) + L2 * np.sin(q[:, 0] + q[:, 1])], axis=1) def jacobian(q): """手先のヤコビアン (N, 2, 2)(第4章)""" s1, c1 = np.sin(q[:, 0]), np.cos(q[:, 0]) s12, c12 = np.sin(q[:, 0] + q[:, 1]), np.cos(q[:, 0] + q[:, 1]) return np.stack([np.stack([-L1 * s1 - L2 * s12, -L2 * s12], axis=1), np.stack([L1 * c1 + L2 * c12, L2 * c12], axis=1)], axis=1) class VecReachEnv: """N 環境を一括で扱う Reach 環境(第7章と同じ構成。報酬だけ切り替えられる)""" def __init__(self, num_envs, seed=0, reward="dense"): if reward not in REWARD_MODES: raise ValueError(f"reward は {REWARD_MODES} のいずれか: {reward!r}") self.n = num_envs self.reward = reward self.rng = np.random.default_rng(seed) self.q = np.zeros((num_envs, 2)); self.qd = np.zeros((num_envs, 2)) self.target = np.zeros((num_envs, 2)) self.step_count = np.zeros(num_envs, dtype=int) self.actions = np.zeros((num_envs, 2)); self.tau = np.zeros((num_envs, 2)) self.prev_dist = np.zeros(num_envs) self.successes = 0; self.episodes = 0 def reset(self): """全環境を初期化して、観測 (N, 10) を返す""" self._reset_idx(np.arange(self.n)) return self._get_observations() # ---- 行動を受け取り、物理を DECIMATION ステップ進める ---- def step(self, actions): self.actions = np.clip(actions, -1.0, 1.0) # 方策の出力は [-1, 1] に正規化 for _ in range(DECIMATION): self._apply_action() self.qd += dynamics(self.q, self.qd, self.tau) * DT # 半陰的オイラー積分 self.q += self.qd * DT self.step_count += 1 obs = self._get_observations() success, timeout = self._get_dones() rew = self._get_rewards(success) terminated = success # 成功してエピソードが終わった truncated = timeout & ~success # 時間切れで打ち切られた done = terminated | truncated self.successes += int(success.sum()); self.episodes += int(done.sum()) final_obs = obs.copy() # リセット前の「最後の観測」 if done.any(): # 終わった環境だけをその場でリセット ids = np.nonzero(done)[0] self._reset_idx(ids) obs[ids] = self._get_observations()[ids] return obs, rew, terminated, truncated, final_obs def _apply_action(self): self.tau = TAU_MAX * self.actions def _get_observations(self): return np.concatenate([np.cos(self.q), np.sin(self.q), self.qd, self.target, self.target - fk(self.q)], axis=1) # (N, 10) def _get_rewards(self, success): dist = np.linalg.norm(self.target - fk(self.q), axis=1) if self.reward == "dense": # 第7章と同じ:距離 + 行動の大きさのペナルティ rew = -dist - 0.01 * np.square(self.actions).sum(axis=1) elif self.reward == "no_action_penalty": # 距離だけ rew = -dist elif self.reward == "sparse": # 成功したときだけ 1 rew = success.astype(float) elif self.reward == "progress": # 近づいたら +1、遠ざかっても罰はない(抜け穴あり) rew = (dist < self.prev_dist).astype(float) else: # potential:γΦ(s') − Φ(s)、Φ = −距離 phi, prev_phi = -dist / PHI_SCALE, -self.prev_dist / PHI_SCALE rew = GAMMA * phi - prev_phi + SUCCESS_BONUS * success self.prev_dist = dist return rew def _get_dones(self): dist = np.linalg.norm(self.target - fk(self.q), axis=1) speed = np.linalg.norm(self.qd, axis=1) return (dist < 0.01) & (speed < 0.2), self.step_count >= EPISODE_STEPS def _reset_idx(self, ids): """指定した環境だけ初期化する(関節角と目標位置をランダム化)""" k = len(ids) self.q[ids] = self.rng.uniform(-np.pi, np.pi, (k, 2)) self.q[ids, 1] = self.rng.uniform(0.3, 2.5, k) * self.rng.choice([-1, 1], k) # 特異姿勢を避ける self.qd[ids] = 0.0 r = self.rng.uniform(0.15, 0.50, k); th = self.rng.uniform(-np.pi, np.pi, k) self.target[ids] = np.stack([r * np.cos(th), r * np.sin(th)], axis=1) self.step_count[ids] = 0 self.prev_dist[ids] = np.linalg.norm(self.target[ids] - fk(self.q[ids]), axis=1) def scripted_policy(obs): """比較用の方策:第0章のヤコビ転置法 + ダンピング(一括で計算)""" q = np.arctan2(obs[:, 2:4], obs[:, 0:2]) qd, err = obs[:, 4:6], obs[:, 8:10] tau = 100.0 * np.einsum("nij,ni->nj", jacobian(q), err) - 1.5 * qd # τ = Kp Jᵀe − Kd q̇ return tau / TAU_MAX 5種類の報酬は、3章の内容に対応しています。 | reward | 定義 | ねらい | |---|---|---| | dense | $-d - 0.01 \sum a^2$ | 第7章と同じ。距離 $d$ と行動の大きさのペナルティ | | no_action_penalty | $-d$ | 行動のペナルティなし(3.2 節) | | sparse | 成功したステップだけ 1 | 疎な報酬(3.1 節) | | progress | 前のステップより近づいたら +1 | 抜け穴のあるヒント(3.3 節) | | potential | $\gamma\Phi(s') - \Phi(s)$ + 成功で +10 | ポテンシャル型のヒント(3.4 節)。$\Phi = -d / 0.01$ | 4.3 SB3 につなぐ:sb3_env.py SB3 には、複数の環境をまとめて扱う VecEnv というインタフェースがあります。「N 環境分の行動を受け取り、N 環境分の観測・報酬・終了フラグを返す」という、第7章の並列環境とまったく同じ考え方です。そこで、VecReachEnv を VecEnv の形に包むだけの薄いアダプタを書きます。計算はすべて VecReachEnv が配列で一括に行います。 """第9章: VecReachEnv を Stable-Baselines3 の VecEnv として使うためのアダプタ SB3 の VecEnv は「N 環境をまとめて step する」インタフェースで、第7章の並列環境と同じ考え方。 中身の計算はすべて VecReachEnv が配列で一括に行い、ここでは型と info の形式を合わせるだけ。 """ import numpy as np from gymnasium import spaces from stable_baselines3.common.vec_env import VecEnv from vec_env import VecReachEnv class SB3ReachEnv(VecEnv): def __init__(self, num_envs, seed=0, reward="dense"): self.raw = VecReachEnv(num_envs, seed=seed, reward=reward) self.raw.render_mode = None # SB3 が問い合わせる属性(描画はしない) super().__init__( num_envs, spaces.Box(-np.inf, np.inf, (10,), np.float32), # 観測:cos q, sin q, q̇, 目標, 目標 − 手先 spaces.Box(-1.0, 1.0, (2,), np.float32), # 行動:正規化した関節トルク ) def reset(self): return self.raw.reset().astype(np.float32) def step_async(self, actions): self._actions = actions def step_wait(self): obs, rew, terminated, truncated, final_obs = self.raw.step(self._actions) dones = terminated | truncated infos = [{} for _ in range(self.num_envs)] for i in np.nonzero(dones)[0]: infos[i]["terminal_observation"] = final_obs[i].astype(np.float32) # 最後の状態の価値の計算に使う infos[i]["TimeLimit.truncated"] = bool(truncated[i]) # 時間切れは「失敗で終了」と区別する infos[i]["is_success"] = bool(terminated[i]) return obs.astype(np.float32), rew.astype(np.float32), dones, infos def close(self): pass # ---- 以下は SB3 の VecEnv が要求するメソッド(この章では最小限の実装) ---- def get_attr(self, attr_name, indices=None): return [getattr(self.raw, attr_name)] * len(list(self._get_indices(indices))) def set_attr(self, attr_name, value, indices=None): setattr(self.raw, attr_name, value) def env_method(self, method_name, *method_args, indices=None, **method_kwargs): return [getattr(self.raw, method_name)(*method_args, **method_kwargs)] def env_is_wrapped(self, wrapper_class, indices=None): return [False] * len(list(self._get_indices(indices))) terminal_observation と TimeLimit.truncated が、4.2 節の変更点 2・3 に対応しています。時間切れで打ち切ったエピソードについては、PPO は「この先もエピソードが続いていたら得られたはずの報酬」を、最後の観測の価値関数で見積もって補います。そのために、リセット前の観測が必要になるのです。 4.4 評価:evaluate.py 学習した方策を、学習とは別のシードで生成した 1024 環境 × 10 秒 で評価します。主な指標は4つです。 - 成功率:終わったエピソードのうち、成功したものの割合 - 平均ステップ:成功したエピソードが、何ステップ(1ステップ = 0.02 秒)で成功したか - トルク変化率:1ステップあたりのトルクの変化量を時間で割ったもの [Nm/s]。[第5章](./05_制御と軌道生成.md)でも使った「動きのなめらかさ」の指標で、小さいほどモーターにやさしい - 時間切れ時の距離:時間切れになったエピソードで、最後に目標まで何 cm 残っていたか(中央値)。失敗の「中身」を見るための指標 """第9章: 方策を評価する(成功率・平均ステップ数・トルクの変化率・時間切れ時の距離・報酬の合計) 使い方: python evaluate.py # 比較用のヤコビ転置法だけを評価 python evaluate.py models/ppo_dense.zip # 学習済みモデルも並べて評価 """ import sys from pathlib import Path import numpy as np from vec_env import DECIMATION, DT, VecReachEnv, scripted_policy def evaluate(policy, num_envs=1024, steps=500, seed=1, reward="dense"): """policy(obs (N,10)) -> 行動 (N,2) を num_envs 環境 × steps ステップ動かして統計を取る 学習(seed=0)とは別のシードで、まだ見ていない初期姿勢・目標を使って評価する。 """ env = VecReachEnv(num_envs, seed=seed, reward=reward) obs = env.reset() ep_return, ep_len = np.zeros(num_envs), np.zeros(num_envs, dtype=int) returns, success_steps, timeout_dists = [], [], [] prev_tau = np.zeros((num_envs, 2)) same_episode = np.zeros(num_envs, dtype=bool) # 前のステップと同じエピソードか(初回とリセット直後は False) dtau_sum, dtau_count = 0.0, 0 for _ in range(steps): obs, rew, terminated, truncated, final_obs = env.step(policy(obs)) # トルクの変化率 |Δτ| / Δt [Nm/s](クリップ後に実際にかかったトルクで計算) dtau = np.abs(env.tau - prev_tau)[same_episode] / (DT * DECIMATION) dtau_sum += dtau.sum(); dtau_count += dtau.size prev_tau = env.tau.copy() ep_return += rew; ep_len += 1 done = terminated | truncated returns += ep_return[done].tolist() success_steps += ep_len[terminated].tolist() timeout_dists += np.linalg.norm(final_obs[truncated, 8:10], axis=1).tolist() # 時間切れの時点で、目標まで何 m 残っていたか ep_return[done] = 0.0; ep_len[done] = 0 same_episode = ~done return { "success_rate": env.successes / max(env.episodes, 1), "episodes": env.episodes, "mean_steps": float(np.mean(success_steps)) if success_steps else float("nan"), "mean_return": float(np.mean(returns)) if returns else float("nan"), "torque_rate": dtau_sum / max(dtau_count, 1), "timeout_dist": float(np.median(timeout_dists)) if timeout_dists else float("nan"), } def sb3_policy(model, vecnorm=None): """SB3 のモデルを policy(obs) の形にする(評価では確率的なノイズを入れず、平均の行動を使う) 学習時に観測を正規化していた場合は、評価でも同じ統計で正規化してから方策に渡す。 """ from stable_baselines3.common.vec_env import VecNormalize norm = vecnorm.normalize_obs if isinstance(vecnorm, VecNormalize) else (lambda o: o) return lambda obs: model.predict(norm(obs.astype(np.float32)), deterministic=True)[0] def load_model(path): """保存したモデル(と、あれば正規化の統計 .pkl)を読み込み、policy(obs) を返す""" import pickle from stable_baselines3 import PPO, SAC path = Path(path) model = (SAC if path.name.startswith("sac") else PPO).load(path, device="cpu") vecnorm = None if path.with_suffix(".pkl").exists(): with open(path.with_suffix(".pkl"), "rb") as f: vecnorm = pickle.load(f) # 統計だけを使う(環境は不要) return sb3_policy(model, vecnorm) def pad(s, width): """全角文字を幅 2 として、表示幅が width になるように右側を空白で埋める""" return s + " " * (width - sum(2 if ord(c) > 0x2E80 else 1 for c in s)) def print_row(name, r): print(f"{pad(name, 24)} {r['success_rate'] * 100:6.1f}% {r['mean_steps']:12.1f} {r['torque_rate']:18.1f}" f" {r['timeout_dist'] * 100:17.1f}") if __name__ == "__main__": print(f"{pad('方策', 24)} {pad('成功率', 7)} {pad('平均ステップ', 12)} トルク変化率[Nm/s] 時間切れ時の距離[cm]") print_row("ヤコビ転置法(第0章)", evaluate(scripted_policy)) for path in sys.argv[1:]: print_row(Path(path).stem, evaluate(load_model(path))) 4.5 学習:train_ppo.py """第9章: PPO で Reach タスクを学習する 使い方: python train_ppo.py # dense 報酬で学習 → models/ppo_dense.zip(+ 正規化の統計 .pkl) python train_ppo.py --reward sparse # 報酬を変えて学習 python train_ppo.py --no-normalize # 観測の正規化なしで学習(失敗例の再現) python train_ppo.py --steps 300000 # 学習ステップ数を減らして短時間で試す """ import argparse import time from pathlib import Path import numpy as np import torch from stable_baselines3 import PPO from stable_baselines3.common.callbacks import BaseCallback from stable_baselines3.common.vec_env import VecMonitor, VecNormalize from evaluate import evaluate, sb3_policy from sb3_env import SB3ReachEnv from vec_env import GAMMA, REWARD_MODES torch.set_num_threads(1) # 小さなネットワークでは、1スレッドのほうが速い(結果もコア数に依存しなくなる) NUM_ENVS = 64 TOTAL_STEPS = 3_000_000 PPO_KWARGS = dict( n_steps=64, # 1回の更新で 64 環境 × 64 ステップ = 4096 個の経験を集める batch_size=1024, n_epochs=10, # 集めた経験を 10 回使い回して方策を更新する gamma=GAMMA, learning_rate=3e-4, clip_range=0.2, # PPO の「1回で変えすぎない」ための上限 ) class ProgressCallback(BaseCallback): """一定間隔で、学習の進み具合(報酬の平均・成功率)を表示する""" def __init__(self, every=250_000): super().__init__() self.every, self.next_print, self.t0 = every, every, time.perf_counter() def _on_step(self): if self.num_timesteps >= self.next_print: self.next_print += self.every rew = np.mean([ep["r"] for ep in self.model.ep_info_buffer]) if self.model.ep_info_buffer else float("nan") succ = np.mean(self.model.ep_success_buffer) * 100 if self.model.ep_success_buffer else float("nan") print(f"{self.num_timesteps:>10,d} steps {time.perf_counter() - self.t0:5.0f} s" f" エピソード報酬 {rew:8.2f} 成功率 {succ:5.1f}%", flush=True) return True def train(reward="dense", total_steps=TOTAL_STEPS, seed=0, num_envs=NUM_ENVS, normalize=True, verbose=True): """PPO で学習し、(モデル, 学習に使った環境, 学習にかかった秒数) を返す""" env = VecMonitor(SB3ReachEnv(num_envs, seed=seed, reward=reward)) # エピソードの報酬・長さを記録する if normalize: env = VecNormalize(env, norm_reward=False) # 観測を「平均 0・標準偏差 1」に揃える(統計は学習中に更新) model = PPO("MlpPolicy", env, seed=seed, device="cpu", verbose=0, **PPO_KWARGS) t0 = time.perf_counter() model.learn(total_steps, callback=ProgressCallback() if verbose else None) return model, env, time.perf_counter() - t0 def save(model, env, path): """モデル(.zip)と、観測の正規化の統計(同名の .pkl)を保存する""" path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) model.save(path) if isinstance(env, VecNormalize): env.save(path.with_suffix(".pkl")) if __name__ == "__main__": p = argparse.ArgumentParser() p.add_argument("--reward", default="dense", choices=REWARD_MODES) p.add_argument("--steps", type=int, default=TOTAL_STEPS) p.add_argument("--seed", type=int, default=0) p.add_argument("--no-normalize", action="store_true") args = p.parse_args() model, env, sec = train(args.reward, args.steps, args.seed, normalize=not args.no_normalize) name = f"ppo_{args.reward}" + ("_nonorm" if args.no_normalize else "") path = Path("models") / f"{name}.zip" save(model, env, path) r = evaluate(sb3_policy(model, env)) print(f"\n学習時間 {sec:.0f} 秒 → {path}") print(f"評価(未知の 1024 環境 × 10 秒): 成功率 {r['success_rate'] * 100:.1f}%" f" / 平均ステップ {r['mean_steps']:.1f} / トルク変化率 {r['torque_rate']:.1f} Nm/s") ポイントは3つです。 - VecNormalize:観測の各次元を、平均 0・標準偏差 1 になるように正規化します。統計は学習中に更新され、評価でも同じ統計を使います(そのために .pkl に保存します)。その効果は、結果の ③ で見ます - PPO_KWARGS:64 環境 × 64 ステップ = 4096 個の経験を集めるたびに、それを 10 回使い回して方策を更新します。clip_range=0.2 が、2.2 節の「1回の更新で変えすぎない」ための上限です - torch.set_num_threads(1):方策のネットワークは 64 ユニット × 2 層と小さいため、PyTorch のスレッドを増やすと、スレッド間の調整のコストのほうが大きくなります。筆者の環境では、既定のスレッド数(筆者の PC では 8)より 1 スレッドのほうが約 2.5 倍速くなりました(20 万ステップの学習で 21.2 秒 → 8.6 秒)。SB3 のドキュメントにも、CNN を使わない PPO は主に CPU で動かすことを想定している、とあります[^sb3-ppo] python train_ppo.py python train_ppo.py --no-normalize python evaluate.py models/ppo_dense.zip models/ppo_dense_nonorm.zip 筆者の環境(Windows 11、CPU のみ)での実行結果です。 250,048 steps 31 s エピソード報酬 -41.20 成功率 0.0% 500,032 steps 59 s エピソード報酬 -28.70 成功率 1.0% 750,016 steps 83 s エピソード報酬 -18.18 成功率 4.0% 1,000,000 steps 108 s エピソード報酬 -12.39 成功率 48.0% 1,250,048 steps 130 s エピソード報酬 -9.52 成功率 76.0% 1,500,032 steps 154 s エピソード報酬 -8.31 成功率 96.0% 1,750,016 steps 177 s エピソード報酬 -8.38 成功率 95.0% 2,000,000 steps 200 s エピソード報酬 -7.49 成功率 94.0% 2,250,048 steps 222 s エピソード報酬 -7.16 成功率 96.0% 2,500,032 steps 240 s エピソード報酬 -7.14 成功率 99.0% 2,750,016 steps 262 s エピソード報酬 -7.21 成功率 99.0% 3,000,000 steps 283 s エピソード報酬 -8.86 成功率 99.0% 学習時間 283 秒 → models\ppo_dense.zip 評価(未知の 1024 環境 × 10 秒): 成功率 94.3% / 平均ステップ 37.2 / トルク変化率 13.9 Nm/s 方策 成功率 平均ステップ トルク変化率[Nm/s] 時間切れ時の距離[cm] ヤコビ転置法(第0章) 86.8% 86.6 32.7 2.7 ppo_dense 94.3% 37.2 13.9 1.3 ppo_dense_nonorm 45.7% 48.5 5.3 3.2 ① 学習は、ある時点から急に進む 学習曲線(エピソード報酬と成功率)を見ると、最初の 75 万ステップほどは、成功率が数 % 以下のままです。この間、方策は「目標の方向へ腕を振る」ことは覚えていきますが(エピソード報酬は −41 から −18 へ改善)、1 cm 以内に止まるところまでは届きません。100 万ステップを過ぎたあたりで成功率が一気に上がり、150 万ステップで 90% を超えました。 300 万ステップは、シミュレーション時間に直すと 300 万 × 0.02 秒 = 約 17 時間分 の試行錯誤です。CPU 1コアで約5分でした。第7章で見た「並列シミュレーションで、実時間よりはるかに速く経験を集める」ことが、そのまま学習の速さになっています。 ② 手書きの制御器を上回った | 方策 | 成功率 | 平均ステップ | トルク変化率 [Nm/s] | |---|---|---|---| | ヤコビ転置法(第0章) | 86.8% | 86.6 | 32.7 | | PPO(観測の正規化あり) | 94.3% | 37.2 | 13.9 | 学習した方策は、ヤコビ転置法より 成功率が高く、2倍以上速く目標に到達し、トルクの変化もなめらか でした。 ただし、これを「強化学習は手書きの制御器より優れている」と読むのは早計です。 - ヤコビ転置法のゲイン($K_p = 100$、$K_d = 1.5$)は、第0章で決めた値のままで、このタスク向けには調整していません。第7章の演習2のように、4096 環境でゲインを探索すれば、差はかなり縮まるはずです - 一方で、ヤコビ転置法を書くには ロボットの運動学(ヤコビアン)を知っている 必要がありました。PPO は、運動方程式もヤコビアンも知らず、観測と報酬だけ からこの動きを見つけています。ここが強化学習の本当の価値です ③ 観測の正規化がなければ、半分も成功しない --no-normalize で学習すると、同じ 300 万ステップでも成功率は 45.7% にとどまりました。さらに、シードを変えると結果が大きく変わります。 | | シード 0 | シード 1 | |---|---|---| | 観測の正規化あり | 94.3% | 96.2% | | 観測の正規化なし | 45.7% | 28.7% | (シード 1 は python train_ppo.py --seed 1 で再現できます) 観測の 10 次元は、スケールがばらばらです。cos・sin は ±1 の範囲ですが、関節速度は数 rad/s まで振れ、目標までのずれは、成功の直前には 0.01 m 以下 になります。評価の表の「時間切れ時の距離」を見ると、正規化なしの方策は、時間切れになったエピソードで 目標まで 3.2 cm(中央値)を残して 終わっていました(正規化ありでは 1.3 cm)。目標の手前までは来ているのに、最後の数 cm を詰めきれていないのです。正規化しないと、ネットワークにとって「最後の数 cm」の違いは入力の中のごく小さな変化でしかなく、そこでの細かな制御を学びにくいのだと考えられます。 SB3 のドキュメントの「RL Tips and Tricks」には、「エージェントへの入力は、常に正規化すべき(PPO/A2C なら VecNormalize などを使う)」 と書かれています[^sb3-tips]。強化学習では、アルゴリズムの選択よりも、こうした 地味な前処理 が結果を大きく左右することがよくあります。 4.6 報酬設計の実験:reward_experiments.py 報酬だけを変えて、ほかの条件(アルゴリズム、ステップ数、シード、正規化)をすべてそろえて学習します。 """第9章: 報酬設計の実験 ― 報酬だけを変えて、同じ条件で PPO を学習・評価する 使い方: python reward_experiments.py # 5種類の報酬 × 300万ステップ(筆者の環境で合計 10〜20 分) python reward_experiments.py --steps 1000000 # 短時間で試す """ import argparse from evaluate import evaluate, pad, sb3_policy from train_ppo import TOTAL_STEPS, train from vec_env import REWARD_MODES if __name__ == "__main__": p = argparse.ArgumentParser() p.add_argument("--steps", type=int, default=TOTAL_STEPS) args = p.parse_args() rows = [] for mode in REWARD_MODES: model, env, sec = train(mode, args.steps, verbose=False) # 成功率は共通の物差し。報酬の合計は「その報酬関数で測った値」なので、モードの間では比べられない r = evaluate(sb3_policy(model, env), reward=mode) rows.append((mode, r, sec)) print(f"{mode:18s} 学習 {sec:4.0f} 秒 / 成功率 {r['success_rate'] * 100:5.1f}%", flush=True) print(f"\n{pad('報酬', 18)} {pad('成功率', 7)} {pad('平均ステップ', 12)} " f"{pad('トルク変化率', 12)} {pad('時間切れ時の距離[cm]', 20)} 1エピソードの報酬") for mode, r, sec in rows: print(f"{mode:18s} {r['success_rate'] * 100:6.1f}% {r['mean_steps']:12.1f} " f"{r['torque_rate']:12.1f} {r['timeout_dist'] * 100:20.1f} {r['mean_return']:17.2f}") python reward_experiments.py dense 学習 188 秒 / 成功率 94.3% no_action_penalty 学習 146 秒 / 成功率 95.3% sparse 学習 112 秒 / 成功率 0.4% progress 学習 114 秒 / 成功率 0.5% potential 学習 133 秒 / 成功率 95.8% 報酬 成功率 平均ステップ トルク変化率 時間切れ時の距離[cm] 1エピソードの報酬 dense 94.3% 37.2 13.9 1.3 -7.32 no_action_penalty 95.3% 34.2 17.6 1.3 -6.55 sparse 0.4% 75.8 8.1 34.2 0.00 progress 0.5% 87.9 361.1 14.6 127.95 potential 95.8% 45.3 16.9 1.5 69.04 「1エピソードの報酬」は、それぞれの報酬関数で測った値なので、行の間では比べられません。共通の物差しは成功率です。 ① sparse 報酬では、ほとんど学べない sparse(成功したときだけ 1)の成功率は 0.4% でした。学習の初期の方策は、ランダムに近い動きをします。ランダムな動きで「1 cm 以内に入って、しかも止まる」ことはまず起きないので、報酬は最後までほとんど 0 のままで、学習の手がかりがありませんでした。時間切れの時点でも、目標まで 34.2 cm(中央値)残っており、目標に向かうことすら覚えていません。3.1 節の 探索の問題 です。同じ「成功してほしい」という意図でも、dense 報酬(成功率 94.3%)とはまったく違う結果になります。 ② 行動のペナルティを外すと、少し荒くなる no_action_penalty は、成功率では dense とほぼ同じ(95.3%)で、トルク変化率が 13.9 → 17.6 Nm/s と約 3 割大きくなりました。動きが荒くなる方向には変わりましたが、筆者の予想よりは小さな差でした。理由は2つ考えられます。1つは、係数 0.01 が小さく、距離の項に比べて弱かったこと。もう1つは、3.2 節で書いたように、このペナルティは トルクの「大きさ」 に対するもので、トルクの「変化」 を直接は罰していないことです。測りたいもの(変化率)と、罰しているもの(大きさ)がずれていれば、効き目が小さいのは当然です。報酬設計では、本当に求めている性質を、そのまま報酬に書けているか を確かめることが大切です(演習1)。 ③ 抜け穴のある報酬は、必ず見つけられる progress(近づいたら +1)は、1エピソードあたり約 128 の報酬を稼ぎながら、成功率は 0.5% でした。そして、トルク変化率は 361 Nm/s、dense の約 26 倍です。 何が起きたのでしょうか。この報酬は「近づいたら +1」で、「遠ざかっても罰はない」ので、少し遠ざかってから、また近づけば、何度でも +1 をもらえます。しかも、成功するとエピソードが終わって、それ以上稼げなくなります。報酬 約 128 は、3秒間(150 ステップ)のうち 8 割以上のステップで「近づいた」と判定されたことを意味します。それなのに、時間切れの時点でも目標まで 14.6 cm(中央値) も残っていました。エージェントは、目標に向かってわずかずつ近づき、ときどき少し戻ってはまた近づく、という細かな往復を、トルクを激しく切り替えながら続け、あえて目標に着かない 方策を見つけたのです。「コースを完走せず、同じ場所を回って得点を稼ぎ続ける」3.3 節の CoastRunners のボートと、同じ構図です。 ヒントの形を、3.4 節のポテンシャル型に変えるだけで(potential)、成功率は 95.8% に戻りました。行って戻ってくると、もらった分だけ引かれるので、往復して稼ぐことができなくなったからです。 報酬は仕様書であり、エージェントは仕様書の抜け穴を、人間よりも先に、確実に見つけます。 強化学習で一番時間がかかるのは、アルゴリズムの実装ではなく、この「抜け穴のない仕様」を書くことです。 4.7 演習 1. vec_env.py の dense 報酬で、行動のペナルティの係数 0.01 を 0.1、1.0 と大きくすると、成功率とトルク変化率はどう変わるでしょうか。次に、ペナルティを前のステップとの差 $\sum (a_t - a_{t-1})^2$ に変えて(前のステップの行動を self.prev_actions などに保存します)、トルク変化率の下がり方を比べてみましょう。 2. train_ppo.py の PPO_KWARGS で、clip_range を 0.2 から 1.0 に広げたり、n_epochs を 30 に増やしたりして、学習曲線が不安定になるか確かめてみましょう。2.2 節の「変えすぎない」工夫の効果が見えます。 3. Stable-Baselines3 の SAC で同じタスクを学習し、同じ環境ステップ数での成功率を PPO と比べてみましょう(ヒント:SAC は環境数を少なく、例えば 4 にします。evaluate.py の load_model は、ファイル名が sac で始まるモデルを SAC として読み込みます)。 4. potential 報酬の SUCCESS_BONUS を 0 にすると、何が起きるでしょうか。ポテンシャル型のヒントは「最適な方策を変えない」ので、本来の報酬(成功)がなくなると、何が最適になるかを考えてみましょう。 5. (発展)_reset_idx で、リンクの質量や関節の粘性を環境ごとにランダムに変えて学習し、ランダム化せずに学習した方策と、質量を変えた環境での成功率を比べてみましょう。第18章の Sim2Real の予習になります。 --- 5. 産業界における強化学習 5.1 歩行:強化学習が最も成功している分野 強化学習が実用レベルで最も成功しているのは、脚ロボットの歩行 です。ETH Zurich の Rudin らは 2021年に、GPU 上で 数千台のロボットを並列にシミュレーション し、4脚ロボット ANYmal の平地の歩行を 4分以内、不整地の歩行を約 20 分で学習させ、実機に移せることを示しました(ワークステーション用の GPU 1枚)[^rudin]。この研究のコードは legged_gym として公開され、そこから生まれた PPO のライブラリ RSL-RL は、現在も Isaac Lab などで使われています[^rslrl]。 脚ロボットの研究を整理したサーベイでも、最適制御と強化学習が「脚ロボットを制御する2つの支配的なアプローチ」とされ、特に PPO がよく使われていると述べられています[^locomotion-survey]。歩行は、「転ばずに、指定した速度で進む」という報酬が比較的書きやすく、シミュレーションで大量に試行できるという、強化学習に向いた条件がそろっています。 5.2 SayCan の「アフォーダンス」は価値関数だった [第1章](./01_ToolCallingからActionへ.md)で紹介した SayCan では、LLM が提案したスキルの「実行可能性(アフォーダンス)」を、強化学習で学習した価値関数 が評価していました[^saycan]。成功すれば 1、失敗すれば 0 という sparse な報酬で、「今の状態から、このスキルが成功する確率」を TD 学習で見積もっています。 興味深いのは、実際にロボットを動かす方策は、主に 人間のデモからの模倣学習(BC) で作られていたことです。論文では、BC の方策のほうが成功率は高いが、RL の方策が与える価値関数が(LLM と組み合わせるうえで)決定的に重要だった、と述べられています。「動かすのは模倣学習、評価するのは強化学習」 という役割分担です。 5.3 VLA を強化学習で鍛える 2025年からは、第12章で扱う VLA(Vision-Language-Action)モデル を、強化学習で追加学習する研究が急増しています。 - π*0.6(RECAP):Physical Intelligence が 2025年11月に発表。VLA を、ロボット自身の経験と人間による修正から強化学習で改善し、難しいタスクではスループットが2倍以上、失敗率がおよそ半分になったと報告しています[^pistar] - SimpleVLA-RL:成功・失敗だけの報酬で、OpenVLA-OFT を強化学習で追加学習し、シミュレーションのベンチマーク(LIBERO)で最高水準の成績を報告しています[^simplevla] - ConRFT:模倣学習で事前学習した方策を、実機で 45〜90 分の強化学習で追加学習し、8つの実タスクで平均 96.3% の成功率を報告しています[^conrft] これらに共通するのは、まず人間のデモで「だいたいできる」方策を作り、そこから強化学習で磨く という順番です。ゼロから強化学習だけで物体操作を学ぶのではありません。 5.4 物体操作の主役は、いまは模倣学習 本章の Reach は、報酬が「距離」で素直に書ける、強化学習にとって易しいタスクでした。しかし、「洗濯物をたたむ」「箱を組み立てる」といった物体操作では、何をもって「うまくたためた」とするかを、報酬として書くこと自体が難しい のです。4.6 節で見たように、書き方を少し間違えるだけで、エージェントは抜け穴を突いてきます。 そのため、筆者の見るところ、現在の物体操作の研究・開発では、人間の操作のデモから学ぶ模倣学習 が中心で、強化学習は5.3 節のような「仕上げ」に使われることが多くなっています。実際、VLA の多くは人間の遠隔操作のデータを模倣して学習されています[^vlarl]。 --- 6. まとめ - 強化学習 は、正解を教えず、報酬だけ を手がかりに試行錯誤で方策を学ぶ。問題は MDP(状態・行動・遷移・報酬・割引率)として整理でき、第7章の並列環境はそのまま MDP の実装になっていた - PPO は「よかった行動を、一度に変えすぎない範囲で増やす」オンポリシーのアルゴリズムで、並列シミュレーションと相性がよく、ロボット学習の標準。SAC は経験を貯めて使い回すオフポリシーのアルゴリズムで、サンプル効率が高く、実機での学習に向く - 実践では、Stable-Baselines3 の PPO で、CPU 1コア・約5分(約 17 時間分の試行錯誤)で Reach を学習し、成功率 94.3% と、未調整のヤコビ転置法(86.8%)を上回った。ただし、強化学習の本当の価値は「上回ったこと」ではなく、運動学を知らずに、観測と報酬だけでここまで来たこと にある - 観測の正規化 を忘れると、成功率は 45.7% に落ち、シードによって大きくばらついた。強化学習では、地味な前処理が結果を大きく左右する - 報酬は仕様書。sparse 報酬では探索がうまくいかず(0.4%)、抜け穴のある報酬では、エージェントは 細かな往復で「近づいた」判定を稼ぎ続け、あえて目標に着かない 方策を見つけた(報酬ハッキング)。ポテンシャル型 の整形にすると、この抜け穴は消えた - 産業界では、強化学習は 歩行 で大きく成功している。物体操作では、報酬を書くこと自体が難しいため 模倣学習が中心 で、強化学習は VLA の「仕上げ」や、SayCan のような価値の評価に使われている 次回予告 第10章「模倣学習(IL)」 では、報酬を書く代わりに、人間のデモをお手本にして 方策を学びます。最も素朴な Behavior Cloning から始め、「デモにない状況に入ると、誤差が雪だるま式に増える」という模倣学習ならではの落とし穴と、それを乗り越える ACT と Diffusion Policy を学びます。[第5章](./05_制御と軌道生成.md)で予告した「アクションチャンク」が、いよいよ登場します。 --- 参考文献 [^ppo]: J. Schulman, F. Wolski, P. Dhariwal, A. Radford, O. Klimov, "Proximal Policy Optimization Algorithms", arXiv:1707.06347, 2017. arxiv.org [^sac]: T. Haarnoja, A. Zhou, P. Abbeel, S. Levine, "Soft Actor-Critic: Off-Policy Maximum Entropy Deep Reinforcement Learning with a Stochastic Actor", ICML 2018, arXiv:1801.01290. arxiv.org [^sac-blog]: T. Haarnoja et al., "Soft Actor Critic—Deep Reinforcement Learning with Real-World Robots", BAIR Blog, 2018-12-14. bair.berkeley.edu [^her]: M. Andrychowicz et al., "Hindsight Experience Replay", NeurIPS 2017, arXiv:1707.01495. arxiv.org [^specgaming]: V. Krakovna et al., "Specification gaming: the flip side of AI ingenuity", Google DeepMind Blog, 2020-04-21. deepmind.google [^coastrunners]: J. Clark, D. Amodei, "Faulty Reward Functions in the Wild", OpenAI, 2016-12-21. openai.com [^ng]: A. Y. Ng, D. Harada, S. Russell, "Policy Invariance Under Reward Transformations: Theory and Application to Reward Shaping", ICML 1999, pp. 278–287. mlanthology.org [^sb3-paper]: A. Raffin et al., "Stable-Baselines3: Reliable Reinforcement Learning Implementations", Journal of Machine Learning Research, 22(268):1–8, 2021. jmlr.org [^sb3-pypi]: PyPI, "stable-baselines3"(2.9.0、2026-06-15). pypi.org [^sb3-ppo]: Stable-Baselines3 Documentation, "PPO". stable-baselines3.readthedocs.io [^sb3-tips]: Stable-Baselines3 Documentation, "Reinforcement Learning Tips and Tricks". stable-baselines3.readthedocs.io [^rudin]: N. Rudin, D. Hoeller, P. Reist, M. Hutter, "Learning to Walk in Minutes Using Massively Parallel Deep Reinforcement Learning", CoRL 2021, arXiv:2109.11978. arxiv.org [^rslrl]: leggedrobotics, "rsl_rl", GitHub. leggedrobotics/rsl_rl [^locomotion-survey]: S. Ha et al., "Learning-based legged locomotion; state of the art and future perspectives", arXiv:2406.01152, 2024. arxiv.org [^saycan]: M. Ahn et al., "Do As I Can, Not As I Say: Grounding Language in Robotic Affordances", CoRL 2022, arXiv:2204.01691. arxiv.org [^pistar]: Physical Intelligence, "π*0.6: a VLA That Learns From Experience", arXiv:2511.14759, 2025. pi.website [^simplevla]: H. Li et al., "SimpleVLA-RL: Scaling VLA Training via Reinforcement Learning", arXiv:2509.09674, 2025. arxiv.org [^conrft]: Y. Chen et al., "ConRFT: A Reinforced Fine-tuning Method for VLA Models via Consistency Policy", RSS 2025, arXiv:2502.05450. arxiv.org [^vlarl]: G. Lu et al., "VLA-RL: Towards Masterful and General Robotic Manipulation with Scalable Reinforcement Learning", arXiv:2505.18719, 2025. arxiv.org