ML-Agents案例之地牢逃脱

本案例源自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人工智能之不断自我进化的五人足球赛

ML-Agents案例之地牢逃脱_第1张图片

环境说明

  • 设置:特工被困在一个有龙的地牢中,必须共同努力才能逃脱。为了取回钥匙,其中一名特工必须找到并杀死龙,为此牺牲自己。龙会掉落一把钥匙供其他人使用。然后其他特工可以拿起这把钥匙并打开地牢门。如果特工花费的时间过长,龙将通过传送门逃跑并且环境会重置。

  • 目标:打开地牢门并离开。

  • 任何智能体成功打开门并离开地牢,则 +1 团队奖励。

  • 此项目的训练难点在于,智能体为了团队奖励,必须学会牺牲自己。

  • 输入:智能体的输入包含一个射线传感器RayPerceptionSensor3D,识别的标签分别为墙,队友,龙,钥匙,门锁,龙的洞穴。共15根射线,参数见下方图片。关于该传感器的详细说明见ML-Agents案例之推箱子游戏。

    ML-Agents案例之地牢逃脱_第2张图片

    ML-Agents案例之地牢逃脱_第3张图片

    除了传感器之外,程序中还加入了一项检测智能体身上是否有钥匙的输入。

  • 输出:智能体只采取了一项的离散输出,其中这个离散输出包含七个只,代表什么都不做、前进、后退、向左走、向右转、向左转、向右转。较少的输出会大大降低神经网络复杂度,减少训练时间。缺点是同一时间只能执行一个动作,降低智能体的灵活性,例如不能同时前进和旋转,也不能前进和向右转等。

    ML-Agents案例之地牢逃脱_第4张图片

代码讲解

首先是标准的三件套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);
        }      
    }
}

订阅事件:

ML-Agents案例之地牢逃脱_第5张图片

其中执行的方法如下:

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

效果演示

后记

这一个案例是多智能体案例,探索了智能体自我牺牲以求团队利益的可能性,以后可以以此为依据,做一个更为复杂的解密类游戏,其中包含人类想不到的解密方法,但智能体可以学习出来,这对于奖励函数的设置是一个巨大的挑战。

你可能感兴趣的:(Unity强化学习,强化学习,unity,强化学习,ML-Agents)