エージェントにDecisionRequester
はアタッチされているでしょうか?あれがあると設定した周期で自動的に行動決定要求を出してくるので、今回の目的には邪魔になるかと思います。もしDecisionRequester
があれば、ひとまずそれは削除してしまうのがいいでしょう。
その上で、下記のようにOnEpisodeBegin
で一発だけRequestDecision
を実行してはいかがでしょうか。
C#
1using Unity.MLAgents;
2using Unity.MLAgents.Actuators;
3using Unity.MLAgents.Sensors;
4using UnityEngine;
5
6public class RockAgent : Agent
7{
8 // 後述のrockに付けるタグ
9 // 別途Tags & Layersでこの名前のタグを登録しておく必要があります
10 static readonly string RockTag = "Rock";
11
12 // 後述の1エピソードの最大時間
13 static readonly float EpisodeTime = 10.0f;
14
15 public GameObject rock;
16 public GameObject rockB;
17
18 // 後述のrockBが置かれている地面オブジェクト
19 public GameObject ground;
20
21 Rigidbody m_Rb_rock;
22 Rigidbody m_Rb_rockB;
23
24 // 後述の岩モデルの初期位置
25 Vector3 initialPosition;
26 Vector3 initialPositionB;
27
28 // 後述のエピソード開始フラグ
29 bool episodeBegun;
30
31 // 後述のエピソード中断二乗距離
32 float distanceThreshold;
33
34 // 後述の残り時間
35 float timeLeft;
36
37 // rockがrockBを押しのけるようにして地面の上に乗り、地面から落ちることなく
38 // いつまでもエピソードが終わらないケースがあるようだったので、地面に
39 // 下記CollisionDetectorをアタッチすることでrockが地面に触れたのを検出し
40 // エピソードを終了させることにしました
41 [DisallowMultipleComponent]
42 [RequireComponent(typeof(Collider))]
43 private class CollisionDetector : MonoBehaviour
44 {
45 public RockAgent Agent { get; set; }
46
47 void OnCollisionEnter(Collision collision)
48 {
49 if (collision.gameObject.CompareTag(RockTag))
50 {
51 Agent.StopEpisode();
52 }
53 }
54 }
55
56 // CollisionDetectorからもエピソードの停止が行えるよう
57 // 停止処理を単独のメソッドとして分離しました
58 void StopEpisode()
59 {
60 episodeBegun = false;
61 SetReward(-1.0f);
62 EndEpisode();
63 }
64
65 public override void Initialize()
66 {
67 m_Rb_rock = rock.GetComponent<Rigidbody>();
68 m_Rb_rockB = rockB.GetComponent<Rigidbody>();
69
70 // 前述のCollisionDetectorを地面にアタッチしておきます
71 var detector = ground.AddComponent<CollisionDetector>();
72 detector.Agent = this;
73
74 // rockにはタグを付けておきます
75 rock.tag = RockTag;
76
77 // 今回の実験では、ご質問者さんの岩モデルとは異なる岩モデルを代用品として
78 // 用意したため、rockの初期位置が(0, 5, 0)、rockBが(0, 0.1, 0)で決め打ちだと
79 // 私の岩モデルでは少々不都合でした
80 // そこで、シーン上に配置した時の位置を初期位置とするようにしました
81 initialPosition = rock.transform.localPosition;
82 initialPositionB = rockB.transform.localPosition;
83
84 // rockとrockBの初期距離の4倍を限界としました
85 distanceThreshold = ((initialPosition - initialPositionB) * 4.0f).sqrMagnitude;
86 }
87
88 public override void OnEpisodeBegin()
89 {
90 rock.transform.localPosition = initialPosition;
91 rock.transform.rotation = Quaternion.Euler(0f, 0f, 0f);
92 m_Rb_rock.velocity = Vector3.zero;
93 m_Rb_rock.angularVelocity = Vector3.zero;
94
95 rockB.transform.localPosition = initialPositionB;
96 rockB.transform.rotation = Quaternion.Euler(0f, 0f, 0f);
97 m_Rb_rockB.velocity = Vector3.zero;
98 m_Rb_rockB.angularVelocity = Vector3.zero;
99
100 // 残り時間を初期値に設定
101 timeLeft = EpisodeTime;
102
103 // エピソード開始時に行動決定およびアクションを要求
104 RequestDecision();
105 }
106
107 public override void CollectObservations(VectorSensor sensor)
108 {
109 // 念のため申し上げますと、今回の状況設定ではエピソード開始時の
110 // 岩の姿勢は常に一定ですので、それをエピソード開始時の1回だけ
111 // 観測したところで変化はなく、行動決定の役には立たなそうな気がします
112 sensor.AddObservation(rock.transform.localPosition);
113 sensor.AddObservation(rock.transform.localEulerAngles);
114 }
115
116 public override void OnActionReceived(ActionBuffers actionBuffers)
117 {
118 // エピソード開始時の回転一発で目標姿勢に向けるのであれば、
119 // 離散アクションより連続アクションの方が適しているんじゃないかと思い
120 // ContinuousActionsを使うよう変更しました
121 var rotation = Quaternion.identity;
122 for (var i = 0; i < 4; i++)
123 {
124 rotation[i] = actionBuffers.ContinuousActions[i];
125 }
126 rotation.Normalize();
127 rock.transform.localRotation = rotation;
128
129 // 行動決定はエピソード開始時の1回しか行わないので
130 // OnActionReceived内で結果を評価するわけにはいかないため
131 // ここではエピソード開始フラグを立てるだけにしました
132 episodeBegun = true;
133 }
134
135 // 結果を評価し報酬を与えるのはFixedUpdate内で行いました
136 void FixedUpdate()
137 {
138 if (!episodeBegun)
139 {
140 return;
141 }
142
143 // rockがおおむね静止している間、残り時間をカウントダウンしていき...
144 if (m_Rb_rock.velocity.sqrMagnitude < 0.01f)
145 {
146 AddReward(1f / EpisodeTime);
147 timeLeft -= Time.deltaTime;
148 }
149
150 // EpisodeTime秒持ちこたえたら、ペナルティなしで
151 // エピソードを終えることにしました
152 if (timeLeft <= 0.0f)
153 {
154 EndEpisode();
155 }
156
157 // ご質問者さんの条件に加えて、何らかの理由で岩が吹っ飛んで行方不明に
158 // なった場合に備え、rockとrockBが限界距離を超えて遠ざかった場合にも
159 // エピソードを中止するようにしました
160 // また、前述のようにCollisionDetectorが衝突を検出した場合も
161 // CollisionDetectorによってエピソードが中断されることになります
162 if (rock.transform.position.y < 0f || (rock.transform.localPosition - rockB.transform.localPosition).sqrMagnitude > distanceThreshold)
163 {
164 StopEpisode();
165 }
166 }
167}
なおコード中のコメントでも申し上げましたが、行動を離散型から連続型に変更(XYZ軸それぞれについて200°/秒で回転するかしないか...の代わりに、目標姿勢を表すクォータニオンの4成分を出力させる)しましたので、エージェントのインスペクター上でContinuous Actionsを4、Discrete Branchesを0に設定しています。

50万回の訓練を行ったところ、下図のように次第に成績が向上し...

確実に成功とまではいきませんでしたが、高確率で安定な姿勢で落とせるようになりました。

念のため申し上げますと、今回の実験ではrock
の初期姿勢しか判断材料に加えていませんので(その初期姿勢にしても、コード中のコメントで申し上げましたように役に立っているとは思えず、実質判断材料なしの手探り)、rock
やrockB
の形を変えたり、あるいはrockB
の位置をずらしただけでも成功率が落ちそうな気がします。
いろいろな岩に対応させるには、何らかの手段で岩の形をエージェントに教えてやる必要があるでしょう。たとえば岩の周りからさまざまな角度でカメラセンサーで撮影する...とかでしょうかね?
高い塔を目指した結果
C#
1using Unity.MLAgents;
2using Unity.MLAgents.Actuators;
3using Unity.MLAgents.Sensors;
4using UnityEngine;
5
6public class RockAgent : Agent
7{
8 static readonly string RockTag = "Rock";
9 static readonly float EpisodeTime = 10.0f;
10
11 public GameObject rock;
12 public GameObject rockB;
13 public GameObject ground;
14
15 float distanceThreshold;
16 bool episodeBegun;
17 Vector3 initialPosition;
18 Vector3 initialPositionB;
19 Rigidbody m_Rb_rock;
20 Rigidbody m_Rb_rockB;
21 float timeLeft;
22
23 void StopEpisode()
24 {
25 // 失敗時のペナルティを増やしました
26 episodeBegun = false;
27 SetReward(-4.0f);
28 EndEpisode();
29 }
30
31 [DisallowMultipleComponent]
32 [RequireComponent(typeof(Collider))]
33 private class CollisionDetector : MonoBehaviour
34 {
35 public RockAgent Agent { get; set; }
36
37 private void OnCollisionEnter(Collision collision)
38 {
39 if (collision.gameObject.CompareTag(RockTag))
40 {
41 Agent.StopEpisode();
42 }
43 }
44 }
45
46 public override void Initialize()
47 {
48 m_Rb_rock = rock.GetComponent<Rigidbody>();
49 m_Rb_rockB = rockB.GetComponent<Rigidbody>();
50 var detector = ground.AddComponent<CollisionDetector>();
51 detector.Agent = this;
52 rock.tag = RockTag;
53 initialPosition = rock.transform.localPosition;
54 initialPositionB = rockB.transform.localPosition;
55 distanceThreshold = ((initialPosition - initialPositionB) * 4.0f).sqrMagnitude;
56 }
57
58 public override void OnEpisodeBegin()
59 {
60 rock.transform.localPosition = initialPosition;
61 rock.transform.rotation = Quaternion.Euler(0f, 0f, 0f);
62 m_Rb_rock.velocity = Vector3.zero;
63 m_Rb_rock.angularVelocity = Vector3.zero;
64 rockB.transform.localPosition = initialPositionB;
65 rockB.transform.rotation = Quaternion.Euler(0f, 0f, 0f);
66 m_Rb_rockB.velocity = Vector3.zero;
67 m_Rb_rockB.angularVelocity = Vector3.zero;
68 timeLeft = EpisodeTime;
69 RequestDecision();
70 }
71
72 public override void CollectObservations(VectorSensor sensor)
73 {
74 sensor.AddObservation(rock.transform.localPosition);
75 sensor.AddObservation(rock.transform.localEulerAngles);
76 }
77
78 public override void OnActionReceived(ActionBuffers actionBuffers)
79 {
80 // 慣性モーメントが最も小さい方角が長軸だと仮定し、その軸を縦に置いた姿勢から
81 // 一定角度(さしあたり15°)以内の範囲で回転量を決めさせることにしました
82 var actions = actionBuffers.ContinuousActions;
83 var inertiaTensor = m_Rb_rock.inertiaTensor;
84 var axis = Vector3.zero;
85 if (inertiaTensor.x < inertiaTensor.y)
86 {
87 if (inertiaTensor.x < inertiaTensor.z)
88 {
89 axis.x = 1.0f;
90 }
91 else
92 {
93 axis.z = 1.0f;
94 }
95 }
96 else
97 {
98 if (inertiaTensor.y < inertiaTensor.z)
99 {
100 axis.y = 1.0f;
101 }
102 else
103 {
104 axis.z = 1.0f;
105 }
106 }
107 var rotation = Quaternion.FromToRotation(
108 m_Rb_rock.inertiaTensorRotation * axis,
109 actions[3] > 0.0f ? Vector3.up : Vector3.down);
110 rotation = Quaternion.AngleAxis(actions[3], Vector3.up) * rotation;
111 var targetDirection = new Vector3(actions[0], actions[1], actions[2]).normalized;
112 if (targetDirection.sqrMagnitude > 0.0f)
113 {
114 rotation = Quaternion.RotateTowards(
115 Quaternion.identity,
116 Quaternion.FromToRotation(Vector3.up, targetDirection),
117 15.0f) * rotation;
118 }
119 rock.transform.localRotation = rotation;
120
121 // 回転だけできれいに乗る位置を決めさせるのは酷なように思い、
122 // 位置を少しずらすことも許すようにしました
123 // ただし、ずれに応じていくらか減点しています
124 var shift = Vector3.ClampMagnitude(
125 new Vector3(actions[4], 0.0f, actions[5]),
126 0.0625f);
127 rock.transform.localPosition += shift;
128 AddReward(-shift.sqrMagnitude * 16.0f);
129
130 episodeBegun = true;
131 }
132
133 void FixedUpdate()
134 {
135 if (!episodeBegun)
136 {
137 return;
138 }
139
140 if (m_Rb_rock.velocity.sqrMagnitude < 0.01f)
141 {
142 // 静止時の獲得報酬が多すぎたように感じ、引き下げました
143 AddReward((4.0f * Time.deltaTime) / EpisodeTime);
144 timeLeft -= Time.deltaTime;
145 }
146
147 if (timeLeft <= 0.0f)
148 {
149 // 適当な高さ(さしあたりinitialPosition.yの2倍)から適当なサイズのBoxCastを行い...
150 var origin = ground.transform.position;
151 if (Physics.BoxCast(
152 new Vector3(origin.x, initialPosition.y * 2.0f, origin.z),
153 new Vector3(1.0f, 1.0f, 0.01f),
154 Vector3.down,
155 out var hitInfo))
156 {
157 // 塔の高さを測定して、高さに応じたボーナス点を与えることにしました
158 // このボーナス点の式は勘で決めたもので、根拠があるわけではありません
159 AddReward(Mathf.Pow(Mathf.Clamp01((hitInfo.point.y - origin.y) / initialPosition.y), 8.0f) * 32.0f);
160 }
161
162 EndEpisode();
163 }
164
165 if ((rock.transform.position.y < 0f) ||
166 ((rock.transform.localPosition - rockB.transform.localPosition).sqrMagnitude >
167 distanceThreshold))
168 {
169 StopEpisode();
170 }
171 }
172}

