はじめに
みなさんこんにちは。Unityエンジニアの小西です。
今回はPythonをまっっったく知らない僕がAIと一緒に機械学習でAIを作ってみたので、その過程などお話ししていければなと思っています。AIに何かを"教える"ことの難しさと面白さの一端が伝われば幸いです。
一部コードを記載していきつつも、基本的にUnity・Pythonなどの専門知識がなくても分かるようにまとめていきますので、最後までお付き合いしていただければ幸いです。
環境構築
今回はMacで環境構築を行いました。必要なパッケージはこちらです。
- Unity Editor (6000.0.40f1)
- 今回はUnity6を利用しましたが、特に深い意味はないのでこのバージョンである必要はありません
- ML-Agents パッケージ (Unity側)
- パッケージ名: com.unity.ml-agents
- バージョン: 3.0.0
- Python
- バージョン: 3.9 〜 3.11
- お使いのML-Agentsのバージョンに対応したものをインストールする必要があります
- unity-mlagents
- バージョン: 1.0.0 (または、Unityパッケージ3.0.0に対応する新しいバージョン)
詳しい導入手順は全部Genimiに聞いて実装したので、試したい方はお近くのLLMにご相談ください。
ステージ作成
まずは簡単な動きからということで、このようなステージを作成しました。左のカプセルがプレイヤー、右のキューブがゴールです。
ステージ中央には穴が空いており、この穴をジャンプして飛び越えないとゴールできないようにしてあります。
プレイヤーのソースコードはこちらです。
using UnityEngine;
using Unity.MLAgents; // ML-Agentsの基本機能を使うために必要
using Unity.MLAgents.Actuators; // AIの行動を定義するために必要
using Unity.MLAgents.Sensors; // AIの観測(センサー)を定義するために必要
/// <summary>
/// シンプルなジャンプアクションでゴールを目指すエージェント
/// </summary>
[RequireComponent(typeof(Rigidbody))] // このスクリプトがアタッチされるオブジェクトには必ずRigidbodyが必要
public class SimpleJumpAgent : Agent // ML-Agentsの「エージェント」として機能するためのクラスを継承
{
// --- Inspectorで設定するパラメータ ---
[Header("エージェント設定")]
[Tooltip("移動速度")]
public float moveSpeed = 5f;
[Tooltip("ジャンプ力")]
public float jumpForce = 8f;
[Header("オブジェクト参照")]
[Tooltip("最終的なゴールのTransform")]
public Transform goalTransform; // ゴールの位置を知るために、Unityエディタからゴールのオブジェクトを割り当てる
[Tooltip("地面を判定するためのレイヤー")]
public LayerMask groundLayer; // 地面として認識させたいオブジェクトのレイヤーをUnityエディタで設定
[Tooltip("足元からRay(光線)を飛ばす開始点")]
public Transform groundCheck; // エージェントの足元に配置した空のオブジェクトなどを割り当てる
// --- 内部で使う変数 ---
private Rigidbody rb; // 物理演算を司るRigidbodyコンポーネント
private Vector3 startPosition; // エピソード開始時の初期位置を記憶
private bool isGrounded; // 地面に接しているかどうかのフラグ
private float m_PreviousDistanceToGoalX; // 1ステップ前のゴールまでの距離を記憶(報酬計算用)
/// <summary>
/// エージェントが最初に初期化されるときに一度だけ呼ばれる処理
/// </summary>
public override void Initialize()
{
// 自身のRigidbodyコンポーネントを取得して変数に保持
rb = GetComponent<Rigidbody>();
// 最初の位置を開始位置として記憶
startPosition = transform.position;
}
/// <summary>
/// 新しいエピソードが始まるたびに呼ばれる処理(ゴールまたは失敗からのリセット時)
/// </summary>
public override void OnEpisodeBegin()
{
// Agentを初期位置に戻す
transform.position = startPosition;
// 物理的な力をリセットして、その場に静止させる
rb.velocity = Vector3.zero;
rb.angularVelocity = Vector3.zero;
// ゴールまでの初期X距離を計算して保存
if (goalTransform != null)
{
m_PreviousDistanceToGoalX = Mathf.Abs(goalTransform.localPosition.x - transform.localPosition.x);
}
}
/// <summary>
/// AIが状況を判断するために、周囲の環境を観測する処理
/// </summary>
public override void CollectObservations(VectorSensor sensor)
{
// --- AIの「目」となる情報をセンサーに追加 ---
// ここで追加した情報の数(合計4つ)を、UnityエディタのBehavior Parameters > Space Sizeに設定する必要がある
sensor.AddObservation(transform.localPosition.x); // 1. 自分のX座標
sensor.AddObservation(rb.velocity.x); // 2. 自分のX軸方向の速度
sensor.AddObservation(isGrounded); // 3. 地面に接しているか (trueなら1, falseなら0)
sensor.AddObservation(goalTransform.localPosition.x - transform.localPosition.x); // 4. ゴールまでのX軸方向の距離(マイナスもあり得る)
}
/// <summary>
/// AIの「頭脳」が決定した行動を受け取り、キャラクターを動かす処理
/// </summary>
public override void OnActionReceived(ActionBuffers actions)
{
// --- AIが決定した行動パターンを受け取る ---
// 行動1: 左右移動 (0:静止, 1:右, 2:左)
int moveAction = actions.DiscreteActions[0];
// 行動2: ジャンプ (0:しない, 1:する)
int jumpAction = actions.DiscreteActions[1];
// --- 受け取った行動を実際の動きに変換 ---
Vector3 moveDir = Vector3.zero;
if (moveAction == 1) moveDir.x = 1; // 右へ
if (moveAction == 2) moveDir.x = -1; // 左へ
// 速度を直接変更して左右に移動させる
rb.velocity = new Vector3(moveDir.x * moveSpeed, rb.velocity.y, 0);
// 地面にいる時だけジャンプできるようにする
if (jumpAction == 1 && isGrounded)
{
// 上向きに瞬間的な力を加えてジャンプさせる
rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
}
// --- 行動の結果に対する報酬を与える ---
// 1. 時間ペナルティ:1ステップごとに小さなマイナス報酬を与え、早くゴールすることを促す
if (MaxStep > 0)
{
AddReward(-1f / MaxStep);
}
// 2. 進行度報酬:ゴールに近づいた分だけプラスの報酬を与える
if (goalTransform != null)
{
float currentDistanceToGoalX = Mathf.Abs(goalTransform.localPosition.x - transform.localPosition.x);
// 「前回の距離」-「現在の距離」= 近づいた距離
float distanceReduced = m_PreviousDistanceToGoalX - currentDistanceToGoalX;
// 近づいた距離に応じた報酬を追加
AddReward(0.01f * distanceReduced);
// 次のステップのために、現在の距離を「前回の距離」として記憶する
m_PreviousDistanceToGoalX = currentDistanceToGoalX;
}
}
/// <summary>
/// 人間がキーボードで操作するための処理(デバッグやデモ記録用)
/// </summary>
public override void Heuristic(in ActionBuffers actionsOut)
{
var discreteActions = actionsOut.DiscreteActions;
discreteActions.Clear();
// 左右矢印キーの入力に応じて、移動アクション(0, 1, 2)を設定
if (Input.GetKey(KeyCode.RightArrow)) discreteActions[0] = 1;
else if (Input.GetKey(KeyCode.LeftArrow)) discreteActions[0] = 2;
else discreteActions[0] = 0;
// スペースキーが押されているかに応じて、ジャンプアクション(0, 1)を設定
discreteActions[1] = Input.GetKey(KeyCode.Space) ? 1 : 0;
}
/// <summary>
/// 物理演算のフレームごとに呼ばれる処理
/// </summary>
void FixedUpdate()
{
// 接地判定:足元の開始点(groundCheck)から真下に短いRayを飛ばし、地面(groundLayer)に当たっているか確認
isGrounded = Physics.Raycast(groundCheck.position, Vector3.down, 0.1f, groundLayer);
}
/// <summary>
/// 他のコライダーのトリガー(Is Triggerがオン)に接触した瞬間に呼ばれる処理
/// </summary>
void OnTriggerEnter(Collider other)
{
// 3. ゴール/落下報酬
if (other.CompareTag("Goal"))
{
AddReward(1.0f); // ゴールしたら大きなプラス報酬
EndEpisode(); // エピソードを成功として終了し、リセット
}
else if (other.CompareTag("FailZone"))
{
AddReward(-1.0f); // 落下ゾーンに落ちたら大きなマイナス報酬
EndEpisode(); // エピソードを失敗として終了し、リセット
}
}
}
コードは全てGeminiに書いてもらいました。パラメータの調整だけ僕の方で行っています。
このコードで行ってることはざっくりこんな感じです。
Agentの行動パターン
1移動→右に進む・左に進む・移動しない
2ジャンプ→ジャンプする・ジャンプしない
報酬
- ゴールしたら大きな報酬
- 穴に落ちたら大きな減点
- 常に微量の減点
- 前回よりゴールに近づくことができたら報酬
これらの行動パターンから報酬が最大になるにはどの選択肢を辿っていくのが良いのか、何万回、何十万回と試行錯誤して学習していきます。
常に微量の減点をしてあげることで、より早くゴールに辿り着いた方が減点は少なくなるので、最適なアクションを見つけやすくなります。
学習開始
ターミナル上でコマンドを叩いてあげると学習を開始できます。学習のためには色々準備しなきゃいけないのですが、今回は割愛させていただきます。
学習の様子はUnity上で確認できます。
ステップを重ねるごとにだんだん落下の回数が減っていって、ログを見ると少しずつ平均の報酬が多くなっていることがわかりますね。
学習結果
いい感じに学習してくれましたね。
穴の手前でちゃんとジャンプしてゴールまで辿り着けていそうです。
最短を探すように微量の減点を設けたおかげで、RTAのような最適化された動きをしています。
ステージを動かしてみる
次にステージを動かしてみます。
理想はどんなステージでも対応できるようなAIを作りたいので、汎用性を持たせていきたいです。
そのために、まずは床が手前に来たタイミングでジャンプしてゴールできるのか実験していきたいと思います。
コードは前回と同じです。
学習結果
…ダメでしたね。
前回より多い回数学習させてみましたが、適当にジャンプして適当に着地しようとしているので、成功しているパターンもまぐれに過ぎません。
ここでの理想は足場が手前まで来るのを待って、手前に来たタイミングでジャンプして乗ることですが、全くできていませんね。
焦らせない
どうすれば”待つ”という動作を覚えてくれるのか考えました。
最初に思いついたのは、少しお話しした微量の減点をなくすことです。
これがあるせいでAIは焦ってジャンプしてしまっているのでは?と思い一旦この減点を消してみることにしました。
該当コードはこちらです。
/// <summary>
/// AIの「頭脳」からの行動決定を受け取り、実行します
/// </summary>
public override void OnActionReceived(ActionBuffers actions)
{
// --- 行動パターンの受け取り ---
// 1. 左右移動 (0:静止, 1:右, 2:左)
int moveAction = actions.DiscreteActions[0];
// 2. ジャンプ (0:しない, 1:する)
int jumpAction = actions.DiscreteActions[1];
// --- 行動の実行 ---
Vector3 moveDir = Vector3.zero;
if (moveAction == 1) moveDir.x = 1; // 右へ
if (moveAction == 2) moveDir.x = -1; // 左へ
// 速度を直接変更して左右に移動
rb.velocity = new Vector3(moveDir.x * moveSpeed, rb.velocity.y, 0);
// 接地している時だけジャンプ可能
if (jumpAction == 1 && isGrounded)
{
rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
}
// --- 報酬 ---
// 「焦らせる」原因と考えていた、常に与えられる微量の減点をコメントアウト
/*
if (MaxStep > 0)
{
AddReward(-1f / MaxStep);
}
*/
}
学習結果
前回とほとんど変わらないですね。
少し後ろに戻るような動きが見られるのは、時間による減点がなくなったからですかね。
時間ペナルティという"焦り"を取り除いただけでは、エージェントが待機する理由にはならなかったようです。
目星をつけさせてみる
次に試したことは目星をつけさせてみることです。
具体的にはAgentの斜め下あたりにRayを飛ばしてみました。
このRayに何も当たってない状態、すなわち目の前に足元がない状態でジャンプすると減点するようにしました。
追加した報酬はこちらです。
- Rayが当たってないのにジャンプしたら減点、逆の場合は報酬を与える
- 動く足場に着地することができた場合、微量の報酬を与える
これらを実装したコードはこちらです。
/// <summary>
/// AIの「頭脳」からの行動決定を受け取り、実行します
/// </summary>
public override void OnActionReceived(ActionBuffers actions)
{
// --- 行動パターンの受け取りと実行 ---
int moveAction = actions.DiscreteActions[0];
int jumpAction = actions.DiscreteActions[1];
bool isTryingToJumpThisStep = (jumpAction == 1 && isGrounded);
float moveX = 0f;
if (moveAction == 1) moveX = 1;
if (moveAction == 2) moveX = -1;
rb.velocity = new Vector3(moveX * moveSpeed, rb.velocity.y, 0f);
if (isTryingToJumpThisStep)
{
rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
}
// --- 新しい報酬設定 ---
// 【報酬1】Rayが当たっているかどうかでジャンプを評価
if (isTryingToJumpThisStep)
{
// m_ForwardRayHitValidPlatformLastObservation は前回の観測で
// 前方に有効な足場を捉えたかを記憶している変数
if (m_ForwardRayHitValidPlatformLastObservation)
{
// "目星"をつけてジャンプしたので、プラスの報酬
AddReward(0.1f);
}
else
{
// "目星"がない(目の前に足場がない)のにジャンプしたので、マイナスの報酬(ペナルティ)
AddReward(-0.1f);
}
}
// 【報酬2】動く足場への着地を評価
// m_IsOnMovingPlatform は足元のRaycastで「現在、動く床に乗っているか」を判定する変数
if (m_IsOnMovingPlatform)
{
// m_PlatformAgentWasOnLastObservation は前回乗っていた足場を記憶した変数
// 以前は動く床に乗っていなかった場合(=つまり、新たに着地した場合)に報酬を与える
bool justLandedOnMovingPlatform = m_PlatformAgentWasOnLastObservation == null ||
!m_PlatformAgentWasOnLastObservation.CompareTag("MovingPlatform");
if (justLandedOnMovingPlatform)
{
AddReward(0.3f); // 動く床への新規着地に、より大きな報酬
}
}
// 基本の時間ペナルティは維持、または調整
if (MaxStep > 0)
{
AddReward(-1f / MaxStep);
}
}
学習結果
…ついにジャンプしても足場に乗ろうとしなくなってしまいましたね。
減点を色々設けた結果、何もしないのが1番報酬が大きいという結論に辿り着いてしまったようです。
これは、AIが『下手に動いてペナルティをもらうリスクを負うより、何もしないのが一番安全でマシな選択だ』という局所最適解に陥ってしまった典型的な例です。
まとめ
AIを作るのはやはり難しいです。
ゲームの中から、ジャンプというメジャーなアクション1つとってもこれだけ苦戦させられました。
ただ、Pythonを全く知らない僕でもAIと一緒なら、これくらいの研究を行うことができました。
今は一旦ジャンプから離れて、迷路探索Agentの研究を行っています。
もし、この記事が好評だったら続編を書いてみようかなと思うので、お気軽にいいね・ストックしていただけると幸いです。
また、感想やアドバイスなどのコメントもお待ちしておりますので、よろしくお願いします。
ここまでご覧いただきありがとうございました。
▼新卒エンジニア研修のご紹介
レアゾン・ホールディングスでは、2025年新卒エンジニア研修にて「個のスキル」と「チーム開発力」の両立を重視した育成に取り組んでいます。
実際の研修の様子や、若手エンジニアの成長ストーリーは以下の記事で詳しくご紹介していますので、ぜひご覧ください!
▼採用情報
レアゾン・ホールディングスは、「世界一の企業へ」というビジョンを掲げ、「新しい"当たり前"を作り続ける」というミッションを推進しています。
現在、エンジニア採用を積極的に行っておりますので、ご興味をお持ちいただけましたら、ぜひ下記リンクからご応募ください。
