本案例源自ML-Agents官方的示例,Github地址:https://github.com/Unity-Technologies/ml-agents,本文是详细的配套讲解。
本文基于我前面发的两篇文章,需要对ML-Agents有一定的了解,详情请见:Unity强化学习之ML-Agents的使用、ML-Agents命令及配置大全。
我前面的相关文章有:
ML-Agents案例之Crawler
ML-Agents案例之推箱子游戏
ML-Agents案例之跳墙游戏
ML-Agents案例之食物收集者
ML-Agents案例之双人足球
Unity人工智能之不断自我进化的五人足球赛
设置:特工被困在一个有龙的地牢中,必须共同努力才能逃脱。为了取回钥匙,其中一名特工必须找到并杀死龙,为此牺牲自己。龙会掉落一把钥匙供其他人使用。然后其他特工可以拿起这把钥匙并打开地牢门。如果特工花费的时间过长,龙将通过传送门逃跑并且环境会重置。
目标:打开地牢门并离开。
任何智能体成功打开门并离开地牢,则 +1 团队奖励。
此项目的训练难点在于,智能体为了团队奖励,必须学会牺牲自己。
输入:智能体的输入包含一个射线传感器RayPerceptionSensor3D,识别的标签分别为墙,队友,龙,钥匙,门锁,龙的洞穴。共15根射线,参数见下方图片。关于该传感器的详细说明见ML-Agents案例之推箱子游戏。
除了传感器之外,程序中还加入了一项检测智能体身上是否有钥匙的输入。
输出:智能体只采取了一项的离散输出,其中这个离散输出包含七个只,代表什么都不做、前进、后退、向左走、向右转、向左转、向右转。较少的输出会大大降低神经网络复杂度,减少训练时间。缺点是同一时间只能执行一个动作,降低智能体的灵活性,例如不能同时前进和旋转,也不能前进和向右转等。
首先是标准的三件套Behavior Parameters、Decision Requester 、Model Overrider。其中只有Behavior Parameters需要调参数,设置见上图。以前已详细讲解了各自的作用,这里不再讲解。
现在看看主要的智能体代码PushAgentEscape.cs:
初始化方法Initialize():
public override void Initialize()
{
// 获取组件
m_GameController = GetComponentInParent();
m_AgentRb = GetComponent();
m_PushBlockSettings = FindObjectOfType();
// 默认没有钥匙
MyKey.SetActive(false);
IHaveAKey = false;
}
每一个episode开始时的处理OnEpisodeBegin()方法:
public override void OnEpisodeBegin()
{
MyKey.SetActive(false);
IHaveAKey = false;
}
状态输入CollectObservations方法:
public override void CollectObservations(VectorSensor sensor)
{
sensor.AddObservation(IHaveAKey);
}
可以看到除了传感器的输入之外,这里只有是否拥有钥匙一个输入。
动作输出OnActionReceived方法:
public override void OnActionReceived(ActionBuffers actionBuffers)
{
MoveAgent(actionBuffers.DiscreteActions);
}
public void MoveAgent(ActionSegment act)
{
var dirToGo = Vector3.zero;
var rotateDir = Vector3.zero;
var action = act[0];
switch (action)
{
case 1:
dirToGo = transform.forward * 1f;
break;
case 2:
dirToGo = transform.forward * -1f;
break;
case 3:
rotateDir = transform.up * 1f;
break;
case 4:
rotateDir = transform.up * -1f;
break;
case 5:
dirToGo = transform.right * -0.75f;
break;
case 6:
dirToGo = transform.right * 0.75f;
break;
}
// 执行旋转
transform.Rotate(rotateDir, Time.fixedDeltaTime * 200f);
// 给刚体施加力,执行移动
m_AgentRb.AddForce(dirToGo * m_PushBlockSettings.agentRunSpeed,
ForceMode.VelocityChange);
}
可以看到这里只有一个离散输出,包含0-6七个值,其中0为什么都不做。
碰撞检测:
碰撞检测分为两个部分,其中洞穴,龙,门锁是碰撞体,调用的是OnCollisionEnter方法:
void OnCollisionEnter(Collision col)
{
// 当身上有钥匙,碰到锁,那么门打开,同时消耗钥匙,调用UnlockDoor方法
if (col.transform.CompareTag("lock"))
{
if (IHaveAKey)
{
MyKey.SetActive(false);
IHaveAKey = false;
m_GameController.UnlockDoor();
}
}
// 当碰到龙时,销毁身上的钥匙(实际上身上此时不可能有钥匙,为了逻辑完整这样写),并且调用KilledByBaddie方法
if (col.transform.CompareTag("dragon"))
{
m_GameController.KilledByBaddie(this, col);
MyKey.SetActive(false);
IHaveAKey = false;
}
// 当碰到洞穴时,调用TouchedHazard方法
if (col.transform.CompareTag("portal"))
{
m_GameController.TouchedHazard(this);
}
}
另一部分是钥匙,它被设定为触发器而非碰撞体,调用的是OnTriggerEnter方法:
void OnTriggerEnter(Collider col)
{
// 如果钥匙是和智能体在同一个父物体下并且智能体为激活状态
// 那么取消激活钥匙并激活身上的子物体钥匙,所以看起来像捡起来钥匙一样
if (col.transform.CompareTag("key") && col.transform.parent == transform.parent && gameObject.activeInHierarchy)
{
print("Picked up key");
MyKey.SetActive(true);
IHaveAKey = true;
col.gameObject.SetActive(false);
}
}
如果玩家想手动操控其中一个智能体,则需要在智能体没有模型的情况下重写Heuristic方法:
public override void Heuristic(in ActionBuffers actionsOut)
{
var discreteActionsOut = actionsOut.DiscreteActions;
if (Input.GetKey(KeyCode.D))
{
discreteActionsOut[0] = 3;
}
else if (Input.GetKey(KeyCode.W))
{
discreteActionsOut[0] = 1;
}
else if (Input.GetKey(KeyCode.A))
{
discreteActionsOut[0] = 4;
}
else if (Input.GetKey(KeyCode.S))
{
discreteActionsOut[0] = 2;
}
}
下面讲解控制整个环境的脚本DungeonEscapeEnvController.cs:
脚本先定义了智能体和恶龙所拥有的信息类,把关键信息封装起来便于调用,使得代码更加简洁美观:
// 定义智能体信息类
public class PlayerInfo
{
// 智能体脚本
public PushAgentEscape Agent;
// 智能体起始位置
public Vector3 StartingPos;
// 智能体起始旋转向量
public Quaternion StartingRot;
// 智能体刚体
public Rigidbody Rb;
// 智能体碰撞体
public Collider Col;
}
// 定义龙信息类
public class DragonInfo
{
// 龙的脚本
public SimpleNPC Agent;
// 龙的起始位置
public Vector3 StartingPos;
// 龙的其实旋转向量
public Quaternion StartingRot;
// 龙的刚体
public Rigidbody Rb;
// 龙的碰撞体
public Collider Col;
// 起始的Transform
public Transform T;
// 是否死亡
public bool IsDead;
}
然后定义了一系列的变量:
// 每一个episode的最大步数和最大时间,超过两者环境会重置
[Header("Max Environment Steps")] public int MaxEnvironmentSteps = 25000;
private int m_ResetTimer;
// 区域大小
public Bounds areaBounds;
// 地面
public GameObject ground;
// 地面材质
Material m_GroundMaterial;
// 地面渲染
Renderer m_GroundRenderer;
// 智能体信息列表
public List AgentsList = new List();
// 龙的信息列表
public List DragonsList = new List();
// 建立一个字典,键为智能体脚本,值为智能体信息
private Dictionary m_PlayerDict = new Dictionary();
// 是否随机智能体的位置和旋转
public bool UseRandomAgentRotation = true;
public bool UseRandomAgentPosition = true;
// 把推方块的脚本拿过来复用了,名字都没改
PushBlockSettings m_PushBlockSettings;
// 存货的智能体数量
private int m_NumberOfRemainingPlayers;
// 钥匙
public GameObject Key;
// 墓碑
public GameObject Tombstone;
// 智能体组(重中之重)
private SimpleMultiAgentGroup m_AgentGroup;
然后就是对场景初始化,调用的Start方法:
void Start()
{
// 获取地面界限
areaBounds = ground.GetComponent().bounds;
// 获取地面渲染,方便改变材质
m_GroundRenderer = ground.GetComponent();
// 初始材质
m_GroundMaterial = m_GroundRenderer.material;
// 获取全局设定脚本
m_PushBlockSettings = FindObjectOfType();
// 重新计算场上存在的智能体
m_NumberOfRemainingPlayers = AgentsList.Count;
// 隐藏钥匙
Key.SetActive(false);
// 给列表中的智能体添加上对应的信息,并把智能体添加到组中,同一组的智能体会相互合作
m_AgentGroup = new SimpleMultiAgentGroup();
foreach (var item in AgentsList)
{
item.StartingPos = item.Agent.transform.position;
item.StartingRot = item.Agent.transform.rotation;
item.Rb = item.Agent.GetComponent();
item.Col = item.Agent.GetComponent();
// 添加到组
m_AgentGroup.RegisterAgent(item.Agent);
}
// 给龙列表中的龙添加信息
foreach (var item in DragonsList)
{
item.StartingPos = item.Agent.transform.position;
item.StartingRot = item.Agent.transform.rotation;
item.T = item.Agent.transform;
item.Col = item.Agent.GetComponent();
}
// 重置场景
ResetScene();
}
在ResetScene中:
void ResetScene()
{
// 重置计时
m_ResetTimer = 0;
// 重置生存的智能体数量
m_NumberOfRemainingPlayers = AgentsList.Count;
// 四个方向任意旋转场景,可以防止过拟合在一个位置上
var rotation = Random.Range(0, 4);
var rotationAngle = rotation * 90f;
transform.Rotate(new Vector3(0f, rotationAngle, 0f));
// 重置列表中的每个智能体
foreach (var item in AgentsList)
{
// 如果设定了随机,在场景中随机一个位置,没有就固定位置
var pos = UseRandomAgentPosition ? GetRandomSpawnPos() : item.StartingPos;
var rot = UseRandomAgentRotation ? GetRandomRot() : item.StartingRot;
item.Agent.transform.SetPositionAndRotation(pos, rot);
// 状态都清零
item.Rb.velocity = Vector3.zero;
item.Rb.angularVelocity = Vector3.zero;
item.Agent.MyKey.SetActive(false);
item.Agent.IHaveAKey = false;
item.Agent.gameObject.SetActive(true);
// 这一行我认为可以去掉,无需再次添加
m_AgentGroup.RegisterAgent(item.Agent);
}
// 重置钥匙
Key.SetActive(false);
// 重置墓碑
Tombstone.SetActive(false);
// 重置列表中的每一只龙
foreach (var item in DragonsList)
{
if (!item.Agent)
{
return;
}
// 设定固定的起始位置
item.Agent.transform.SetPositionAndRotation(item.StartingPos, item.StartingRot);
// 设定随机的行走速度
item.Agent.SetRandomWalkSpeed();
// 激活智能体
item.Agent.gameObject.SetActive(true);
}
}
在获取任意场景中位置的时候,调用的是GetRandomSpawnPos,这段代码可复用很强。
public Vector3 GetRandomSpawnPos()
{
var foundNewSpawnLocation = false;
var randomSpawnPos = Vector3.zero;
while (foundNewSpawnLocation == false)
{
var randomPosX = Random.Range(-areaBounds.extents.x * m_PushBlockSettings.spawnAreaMarginMultiplier,
areaBounds.extents.x * m_PushBlockSettings.spawnAreaMarginMultiplier);
var randomPosZ = Random.Range(-areaBounds.extents.z * m_PushBlockSettings.spawnAreaMarginMultiplier,
areaBounds.extents.z * m_PushBlockSettings.spawnAreaMarginMultiplier);
randomSpawnPos = ground.transform.position + new Vector3(randomPosX, 1f, randomPosZ);
// 检查生成的位置有没有碰撞体,有的话就重新生成,没有就退出循环
if (Physics.CheckBox(randomSpawnPos, new Vector3(2.5f, 0.01f, 2.5f)) == false)
{
foundNewSpawnLocation = true;
}
}
return randomSpawnPos;
}
接下来是每0.02秒都执行一次的FixedUpdate方法:
这里主要检测一个episode是否已经到达了设定的时间和最大步数,满足两者则环境重置。
void FixedUpdate()
{
m_ResetTimer += 1;
if (m_ResetTimer >= MaxEnvironmentSteps && MaxEnvironmentSteps > 0)
{
m_AgentGroup.GroupEpisodeInterrupted();
ResetScene();
}
}
接下来定义了三个对应接触龙,接触洞穴,接触门锁的方法:
当智能体接触洞穴时:
public void TouchedHazard(PushAgentEscape agent)
{
// 智能体死亡,数量-1,数量为0时重置环境
m_NumberOfRemainingPlayers--;
if (m_NumberOfRemainingPlayers == 0 || agent.IHaveAKey)
{
m_AgentGroup.EndGroupEpisode();
ResetScene();
}
else
{
agent.gameObject.SetActive(false);
}
}
当智能体接触门锁时:
public void UnlockDoor()
{
// 获得集体奖励
m_AgentGroup.AddGroupReward(1f);
// 改变地面材质0.5秒
StartCoroutine(GoalScoredSwapGroundMaterial(m_PushBlockSettings.goalScoredMaterial, 0.5f));
print("Unlocked Door");
// 结束游戏
m_AgentGroup.EndGroupEpisode();
// 重置场景
ResetScene();
}
当智能体接触龙时:
public void KilledByBaddie(PushAgentEscape agent, Collision baddieCol)
{
// 龙被杀死,隐藏
baddieCol.gameObject.SetActive(false);
// 一个智能体死亡,隐藏
m_NumberOfRemainingPlayers--;
agent.gameObject.SetActive(false);
print($"{baddieCol.gameObject.name} ate {agent.transform.name}");
// 激活墓碑
Tombstone.transform.SetPositionAndRotation(agent.transform.position, agent.transform.rotation);
Tombstone.SetActive(true);
// 激活钥匙
Key.transform.SetPositionAndRotation(baddieCol.collider.transform.position, baddieCol.collider.transform.rotation);
Key.SetActive(true);
}
此处可以试试扣除接触龙智能体本身的分数,看看智能体是否舍己为人,牺牲自己的分数换取团队的收益。
改变地面材质的携程:
IEnumerator GoalScoredSwapGroundMaterial(Material mat, float time)
{
m_GroundRenderer.material = mat;
yield return new WaitForSeconds(time); // Wait for 2 sec
m_GroundRenderer.material = m_GroundMaterial;
}
以下是NPC龙的代码,很简单,只有移动的逻辑:
using UnityEngine;
public class SimpleNPC : MonoBehaviour
{
public Transform target;
private Rigidbody rb;
public float walkSpeed = 1;
private Vector3 dirToGo;
// 比Start更早执行
void Awake()
{
rb = GetComponent();
}
void Update()
{
}
// 每0.02秒执行一次
void FixedUpdate()
{
dirToGo = target.position - transform.position;
dirToGo.y = 0;
rb.rotation = Quaternion.LookRotation(dirToGo);
// 执行移动
rb.MovePosition(transform.position + transform.forward * walkSpeed * Time.deltaTime);
}
// 设置一个随机速度
public void SetRandomWalkSpeed()
{
walkSpeed = Random.Range(1f, 7f);
}
}
在龙下还挂着一个脚本,用来检测龙是否接触到洞穴:
using UnityEngine;
using UnityEngine.Events;
namespace Unity.MLAgentsExamples
{
public class CollisionCallbacks : MonoBehaviour
{
// 以下定义了多个事件,需要在Unity编辑器中订阅它们
[System.Serializable]
public class TriggerEvent : UnityEvent
{
}
[Header("Trigger Callbacks")]
public TriggerEvent onTriggerEnterEvent = new TriggerEvent();
// 这个案例只用到了这个方法,其他方法都没有订阅
private void OnCollisionEnter(Collision col)
{
if (col.transform.CompareTag(tagToDetect))
{
onCollisionEnterEvent.Invoke(col, transform);
}
}
}
订阅事件:
其中执行的方法如下:
public void BaddieTouchedBlock()
{
m_AgentGroup.EndGroupEpisode();
StartCoroutine(GoalScoredSwapGroundMaterial(m_PushBlockSettings.failMaterial, 0.5f));
ResetScene();
}
最简单的配置:
behaviors:
DungeonEscape:
trainer_type: poca
hyperparameters:
batch_size: 1024
buffer_size: 10240
learning_rate: 0.0003
beta: 0.01
epsilon: 0.2
lambd: 0.95
num_epoch: 3
learning_rate_schedule: constant
network_settings:
normalize: false
hidden_units: 256
num_layers: 2
vis_encode_type: simple
reward_signals:
extrinsic:
gamma: 0.99
strength: 1.0
keep_checkpoints: 5
max_steps: 20000000
time_horizon: 64
summary_freq: 60000
这一个案例是多智能体案例,探索了智能体自我牺牲以求团队利益的可能性,以后可以以此为依据,做一个更为复杂的解密类游戏,其中包含人类想不到的解密方法,但智能体可以学习出来,这对于奖励函数的设置是一个巨大的挑战。