ML-Agents(六)Tennis

ML-Agents(六)Tennis

喜欢的童靴希望大家多多点赞收藏哦~

这次Tennis示例研究费了我不少劲,倒不是因为示例的难度有多大,而重点是这个示例的训练过程中遇到了许多问题值得记录下来,其次这个训练是一个对抗训练,也是比较有意思的示例。

一、Tennis介绍

首先来看看效果~

teenis

OK,可以看到画面中有18个网球场,然后蓝色的球拍和紫色的球拍互相对打。这里注意一下,场景虽然都是3D的,但实际上球拍和球只在球场的中轴线上上下左右移动,也就是说其实换个相机位置的话,这里其实是个二维打球模拟。

teenis2

当然了,这样算是简化了训练的过程,这个示例大部分所用到的内容和3D Ball差不多,主要有一个可以深化学习的就是对抗训练。下面我们来先看一下官方对该示例的参数。

二、环境与训练参数

老规矩,先来看一下官方文档参数:

  • 设定:两个agents控制球拍进行双人游戏,来回击打球过球网

  • 目标:一方agent必须打击球,以使对手无法击回球

  • Agent:在这个环境中,包含两个拥有相同行为参数(Behavior Parameters)的Agent。官方还建议,当你训练好你的球拍Agent后,可以把其中一个球拍调整为Heuristic Only手动操作,尝试一下被电脑虐的快感(当然官方不是这么说的,但是确实有点困难= =)。这里要设置成手动模式,还需要修改一下代码,后面会提到

  • Agent奖励设定:

    • 如果agent赢下一球,则+1。当然由于是对抗训练,所以一方agent要通过防止另一方agent赢球来赢取奖励
    • 如果agent输掉一球,则-1
  • 行为参数:

    • 矢量观测空间:一共9个变量,分别对应球和球拍的位置、速度以及方向
    • 矢量动作空间:Continuous类型,一共3个变量,对应球拍向网、远离网的运动,跳跃和旋转。这里通俗点讲就是球拍的x、y方向运动,以及球拍绕自身z轴的旋转
    • 视觉观测值:None
  • 可变参数:3个

    • 重力加速度:
      • Default:9.81
      • 推荐最小值:6
      • 推荐最大值:20
    • 网球比例:球三个维度上的比例(即x、y、z比例相同)
      • Default:5
      • 推荐最小值:0.2
      • 推荐最大值:5
    • 球拍初始角度:
      • Default:55(源码里这么写了)

    最后一个“球拍初始角度”官方漏写了,我说为啥明明写了3个参数,却只有两个参数写出来。但是!!!凡事有个但是,实际上通过源码会发现,最后这个参数无论怎么改,并不会改变训练时的效果,因为训练时会让Agent自动调整球拍的旋转角度。其实这个参数的唯一作用就是你在手动操作和你训练出来的agent找虐时,会使球拍固定在一个角度来打球,当然,如果你还有余力可以调整球拍角度,那你很sei li~对于agent没啥大影响,对于手动来说,那就是天差地别了。

三、场景基本结构

场景中包含18个球场,如下图:

image-20200407230705102

相应的Hierarchy层级:

image-20200407230725819

场景中Camera、Canvas啥的都不用太多去介绍,TennisSettings可以设置环境一些高级物理设置,例如Unity中的Physics.gravityTime.fixedDeltaTimePhysics.defaultSolverIterations等。

TennisArea是作为一个训练的基本单位。其中:

  • TennisArea

    父物体上本来就带有一个TennisArea.cs脚本,这个脚本主要是重置比赛环境,例如每次开始球的位置、比例等,但是我认为在这里初始化球的比例有问题,这个我们后面说。

  • Ball

    球,带有刚体,存在HitWall.cs脚本,你会发现有意思的是,原来我们看的示例agent的奖惩基本都在AgentAction()函数中,即随时判断agent是否奖惩,但是这里将agent奖励或失败设置放到了球上,由球来决定到底是agentA赢还是agentB赢,然后重置agent。当然这也比较好理解,因为规则都在球上,球当然知道到底谁赢了(感觉有点邪恶。。。。2333)。

image-20200407231415130

  • Invisible Walls

    如英文注释,就是透明碰撞体,场地两边地面各一个,场后各一个,就是为了防止球掉落。当然我想吐槽一下这个碰撞体,设置的太粗旷了= =,两边的墙巨长无比。

image-20200407231943303

  • Scenery

    球场四周的碰撞体,网的碰撞体以及网上的碰撞体。

image-20200407233616918

  • AgentAAgentB

    两个agent,分别代表两对抗方,公用一套训练参数进行训练。

image-20200407234006882

这里两个agent大家注意,看一下有啥不一样,首先看Behavior Parameters组件:

image-20200408205445361

image-20200408205513237

可以看到两个球拍的行为参数,Team Id是不同的,表示了两个不同的阵营,猜测如果有多方阵营也可以训练,还有这里应该是使用Behavior Name来保证训练的Brain一致,在前几面的文章中也有提及。

再来看一下两个球拍的Tennis Agent

image-20200408205707097

image-20200408205717457

会发现一个Invert X勾选,另一个未勾选,这里先卖个关子,一会儿讲为什么这么设置。

下面直接开始分析代码,走起。

四、代码分析

环境初始化脚本

这个示例中环境初始化分为两个部分,一个部分是处于父节点的TennisArea脚本,另一个是球上的HitWall脚本。前者主要在环境重置时初始化球的位置,以及限制球的速度;后者则是包含了打球规则以及初始化球拍、调用TennisArea来初始化比赛。

先来看一下TennisArea.cs脚本。

using UnityEngine;

public class TennisArea : MonoBehaviour
{
    public GameObject ball;//球
    public GameObject agentA;//球拍agentA
    public GameObject agentB;//球拍agentB
    Rigidbody m_BallRb;//球的刚体

    void Start()
    {
        m_BallRb = ball.GetComponent<Rigidbody>();
        MatchReset();//一开始重置比赛,注意这里运行的顺序后于Agent的InitializeAgent()
    }
    /// <summary>
    /// 重置比赛
    /// </summary>
    public void MatchReset()
    {
        var ballOut = Random.Range(6f, 8f);//球随机x值
        var flip = Random.Range(0, 2);//随机球出现在左边还是右边
        if (flip == 0)
        {//令球在场地左边随机出现
            ball.transform.position = new Vector3(-ballOut, 6f, 0f) + transform.position;
        }
        else
        {//令球在场地右边随机出现
            ball.transform.position = new Vector3(ballOut, 6f, 0f) + transform.position;
        }
        m_BallRb.velocity = new Vector3(0f, 0f, 0f);//使球的速度变为0,然后令其自由落体
        //注意:这里修改小球的比例,我认为在这里修改球的比例是不合适的
        //ball.transform.localScale = new Vector3(.5f, .5f, .5f);
        ball.GetComponent<HitWall>().lastAgentHit = -1;//重置HitWall中判断胜利参数
    }

    void FixedUpdate()
    {
        //这里主要是控制球的速度不要太快
        var rgV = m_BallRb.velocity;
        m_BallRb.velocity = new Vector3(Mathf.Clamp(rgV.x, -9f, 9f), Mathf.Clamp(rgV.y, -9f, 9f), rgV.z);
        //Debug.Log(m_BallRb.velocity);
    }
}

这个脚本有几个点来说一下:

  • 首先,一开始运行时,以上的Start()函数是晚于球拍Agent的初始化方法InitializeAgent()的,即MatchReset()方法是在InitializeAgent()之后的。在源码里,TennisArea脚本第32行设置了球的比例,而注意球的比例是可变参数(现在官方改成Parameter Randomization了,即随机化参数,原来是Generalized Reinforcement,即可变强化训练,叫法改了用法一样,就是可变参数)。因此,不管在Agent的InitializeAgent()里如何设置球的比例,在后执行的MatchReset()方法里都会将球的比例重新设置回(0.5f,0.5f,0.5f)。

    以上还只是列举了训练一开始情况,蛋疼的是在训练过程中,也会出现MatchReset()在Agent的SetBall()之后的情况,也就是如果你在训练引入了可变参数球的size,在训练过程中应该是要一定步骤后改变球的比例来训练,但是如果在MatchReset()中设置球的比例,会使得球的比例一直被固定为0.5,因此应该是要去掉这一行的。

    当然,以上推测只是我初看代码理解的,后面我们可以分别注释这一句和加上这一句来进行可变size参数训练,观察tensorboard的曲线就立马能看出来了。

  • 第二点是FixedUpdate()函数,这里将网球的x方向速度和y方向速度限制到了-9到9,当然我们可以把这里注释掉,看看是什么效果。

    tennis3

    这里会发现球的速度过快,下面的Debug也会发现速度很容易就在十几、二十几,球太容易飞出场外。

OK,TennisArea脚本应该没什么问题了,我们来分析一下网球上的HitWall脚本。

HitWall.cs

using UnityEngine;

public class HitWall : MonoBehaviour
{
    public GameObject areaObject;//父节点
    public int lastAgentHit;//最后一次哪个agent击球,0代表agentA击球,1代表agentB击球
    public bool net;//判断是否过网

    public enum FloorHit
    {
        Service,//从空中发球
        FloorHitUnset,//成功回击球之后,使用该标志位
        FloorAHit,//在A地面弹起
        FloorBHit//在B地面弹起
    }

    public FloorHit lastFloorHit;//最后一次地板击中状态

    TennisArea m_Area;
    TennisAgent m_AgentA;//代理A
    TennisAgent m_AgentB;//代理B

    void Start()
    {
        m_Area = areaObject.GetComponent<TennisArea>();
        m_AgentA = m_Area.agentA.GetComponent<TennisAgent>();
        m_AgentB = m_Area.agentB.GetComponent<TennisAgent>();
    }

    /// <summary>
    /// 比赛重置,包括agentA、agentB、网球置位等
    /// </summary>
    void Reset()
    {
        m_AgentA.Done();
        m_AgentB.Done();
        m_Area.MatchReset();
        lastFloorHit = FloorHit.Service;
        net = false;
    }
    /// <summary>
    /// agentA赢
    /// </summary>
    void AgentAWins()
    {
        m_AgentA.SetReward(1);
        m_AgentB.SetReward(-1);
        m_AgentA.score += 1;
        Reset();

    }
    /// <summary>
    /// agentB赢
    /// </summary>
    void AgentBWins()
    {
        m_AgentA.SetReward(-1);
        m_AgentB.SetReward(1);
        m_AgentB.score += 1;
        Reset();

    }
    void OnCollisionEnter(Collision collision)
    {
        if (collision.gameObject.CompareTag("iWall"))
        {//如果球碰到墙(Tag=="iWall"),主要是"InvisibleWalls"和"Scenery"物体下的透明碰撞体
            if (collision.gameObject.name == "wallA")
            {//如果球碰到A这边的墙
                if (lastAgentHit == 0 || lastFloorHit == FloorHit.FloorAHit)
                {//A自己击球碰到A墙出界||球经过A这边的地面弹起碰到A墙出界(A没有接到球),则B赢
                    AgentBWins();
                }
                else
                {//B击球的情况下:若在发球时,B第一球未碰到A地面直接出界||回球时球碰到了B自己边的地面||回球时,球出A边界,则A赢
                    AgentAWins();
                }
            }
            else if (collision.gameObject.name == "wallB")
            {//同上
                if (lastAgentHit == 1 || lastFloorHit == FloorHit.FloorBHit)
                {
                    AgentAWins();
                }
                else
                {
                    AgentBWins();
                }
            }
            else if (collision.gameObject.name == "floorA")
            {//如果球碰到A这边的地面
                if (lastAgentHit == 0 || lastFloorHit == FloorHit.FloorAHit || lastFloorHit == FloorHit.Service)
                {//A击球碰到自己的地面||最后一次也是在A地面弹起,即球在A地面弹了两次||最后一次是B从空中发的球,A未接到球,则B赢
                    AgentBWins();
                }
                else
                {//以上情况都不是,则A接到球并成功回击
                    lastFloorHit = FloorHit.FloorAHit;
                    if (!net)
                    {//A成功接球并回击
                        net = true;
                    }
                }
            }
            else if (collision.gameObject.name == "floorB")
            {//同上
                if (lastAgentHit == 1 || lastFloorHit == FloorHit.FloorBHit || lastFloorHit == FloorHit.Service)
                {
                    AgentAWins();
                }
                else
                {
                    lastFloorHit = FloorHit.FloorBHit;
                    if (!net)
                    {
                        net = true;
                    }
                }
            }
            else if (collision.gameObject.name == "net" && !net)
            {//如果球碰到网,且未判定过网(即net=false)
                if (lastAgentHit == 0)
                {//如果上次击球为A,则B赢
                    AgentBWins();
                }
                else if (lastAgentHit == 1)
                {//如果上次击球为B,则A赢
                    AgentAWins();
                }
            }
        }
        else if (collision.gameObject.name == "AgentA")
        {//球碰到球拍A
            if (lastAgentHit == 0)
            {//如果上一次A已经击打过,即A击球两次,则B赢
                AgentBWins();
            }
            else
            {//A成功回球
                if (lastFloorHit != FloorHit.Service && !net)
                {//A在空中直接回球||球在A地面弹起后A回球(即lastFloorHit=FloorAHit||FloorHitUnset)
                    //则判定过网
                    net = true;
                }

                lastAgentHit = 0;//使最后一次击球为A
                lastFloorHit = FloorHit.FloorHitUnset;//重置击球过程
            }
        }
        else if (collision.gameObject.name == "AgentB")
        {//同上
            if (lastAgentHit == 1)
            {
                AgentAWins();
            }
            else
            {
                if (lastFloorHit != FloorHit.Service && !net)
                {
                    net = true;
                }

                lastAgentHit = 1;
                lastFloorHit = FloorHit.FloorHitUnset;
            }
        }
    }
}

这个脚本,比较复杂的部分就是判断谁赢的逻辑,其实就是将网球的规则编程代码了,熟悉网球规则的童靴应该看一下再想想逻辑就明白了。当然这里也可以做了解,毕竟属于业务部分的东西。

Agent脚本

Agent初始化与重置

首先来看一下Agent脚本中对于agent的初始化、重置部分以及变量参数。

public class TennisAgent : Agent
{
    [Header("Specific to Tennis")]
    public GameObject ball;//网球对象
    public bool invertX;//镜像标志位
    public int score;//得分
    public GameObject myArea;//平台
    public float angle;//球拍角度
    public float scale;//网球比例

    Text m_TextComponent;//得分板Text
    Rigidbody m_AgentRb;//球拍刚体
    Rigidbody m_BallRb;//网球刚体
    float m_InvertMult;//镜像翻转乘数
    IFloatProperties m_ResetParams;//可变参数(可变参数)

    const string k_CanvasName = "Canvas";
    const string k_ScoreBoardAName = "ScoreA";
    const string k_ScoreBoardBName = "ScoreB";

    public override void InitializeAgent()
    {
        m_AgentRb = GetComponent<Rigidbody>();
        m_BallRb = ball.GetComponent<Rigidbody>();
        //找球拍自己对应的得分板,不赘述
        var canvas = GameObject.Find(k_CanvasName);
        GameObject scoreBoard;
        m_ResetParams = Academy.Instance.FloatProperties;
        if (invertX)
        {
            scoreBoard = canvas.transform.Find(k_ScoreBoardBName).gameObject;
        }
        else
        {
            scoreBoard = canvas.transform.Find(k_ScoreBoardAName).gameObject;
        }
        m_TextComponent = scoreBoard.GetComponent<Text>();
        SetResetParameters();//设置可变参数
    }
    /// <summary>
    /// 设置球拍角度
    /// </summary>
    public void SetRacket()
    {
        angle = m_ResetParams.GetPropertyWithDefault("angle", 55f);
        gameObject.transform.eulerAngles = new Vector3(
            gameObject.transform.eulerAngles.x,
            gameObject.transform.eulerAngles.y,
            m_InvertMult * angle
        );
    }
    /// <summary>
    /// 设置网球比例
    /// </summary>
    public void SetBall()
    {
        scale = m_ResetParams.GetPropertyWithDefault("scale", .5f);
        ball.transform.localScale = new Vector3(scale, scale, scale);
    }
    /// <summary>
    /// 设置可变参数
    /// </summary>
    public void SetResetParameters()
    {
        SetRacket();
        SetBall();
    }
    /// <summary>
    /// Agent重置
    /// </summary>
    public override void AgentReset()
    {
        //根据inverX值来将m_InverMul置为1或-1
        m_InvertMult = invertX ? -1f : 1f;
        //球拍位置重置,X随机(前后),Y(高度)与Z(左右)位置固定
        transform.position = new Vector3(-m_InvertMult * Random.Range(6f, 8f), -1.5f, -1.8f) + transform.parent.transform.position;
        //球拍速度置0
        m_AgentRb.velocity = new Vector3(0f, 0f, 0f);
        //设置可变参数
        SetResetParameters();
    }
}

OK,以上代码其实大部分都很简单,但是关于两个变量invertXm_InvertMult,我给它们分别取名为“镜像标志位”和“镜像翻转乘数”。从名字上也可以看出些许猫腻,简单来讲,invertX区分了两个球拍,且决定了m_InvertMult的值是1还是-1,而m_InvertMult则是一个使得两个对立的球拍方向统一化的乘数,这样就可以使得两个球拍虽然是对手,但是经过镜像翻转乘数,实际上转换后,可以看成两个球拍都是向同一个方向训练。

当然在球拍位置初始化、角度初始化时,m_InvertMult也有作用,如下图:

image-20200409234454878

image-20200409234521430

可以看到两个球拍的TransformX与RotationZ为正负相反,这也是为什么在上述代码中,对于球拍初始化位置和角度要乘以m_InvertMult

矢量观测空间

我们在之前知道了该示例的观测空间是Continous类型的,且变量有9个,下面来看一下。

/// <summary>
    /// 矢量观测空间
    /// </summary>
    /// <param name="sensor"></param>
    public override void CollectObservations(VectorSensor sensor)
    {
        //球拍与场地的x值相对位置(前后)
        sensor.AddObservation(m_InvertMult * (transform.position.x - myArea.transform.position.x));
        //球拍与场地的y值相对位置(高低)
        sensor.AddObservation(transform.position.y - myArea.transform.position.y);
        //球拍x方向的速度
        sensor.AddObservation(m_InvertMult * m_AgentRb.velocity.x);
        //球拍y方向的速度
        sensor.AddObservation(m_AgentRb.velocity.y);

        //球与场地的x值相对位置
        sensor.AddObservation(m_InvertMult * (ball.transform.position.x - myArea.transform.position.x));
        //球与场地的y值相对位置
        sensor.AddObservation(ball.transform.position.y - myArea.transform.position.y);
        //球x方向的速度
        sensor.AddObservation(m_InvertMult * m_BallRb.velocity.x);
        //球y方向的速度
        sensor.AddObservation(m_BallRb.velocity.y);

        //球拍的旋转角度
        sensor.AddObservation(m_InvertMult * gameObject.transform.rotation.z);
    }

总体来说,这里收集了球拍和场地、球和场地的相对位置以及它们各自的速度信息,这里再利用图来讲一下镜像翻转乘数。

image-20200410000108289

这样通过m_InverMult,则可以使得两个球拍输出的观察参数变为同向,使得对抗训练对于一个训练单元的训练效果变为double。对于球拍的旋转角度也是同样的道理。

Agent动作反馈

下面我们来看代理的AgentAction()方法。

	/// <summary>
    /// agent动作反馈
    /// </summary>
    /// <param name="vectorAction"></param>
    public override void AgentAction(float[] vectorAction)
    {
        //限制球拍x、y方向(左右、上下)乘积系数为-1到1
        var moveX = Mathf.Clamp(vectorAction[0], -1f, 1f) * m_InvertMult;
        var moveY = Mathf.Clamp(vectorAction[1], -1f, 1f);
        //限制球拍每次旋转角度乘积系数为-1到1
        var rotate = Mathf.Clamp(vectorAction[2], -1f, 1f) * m_InvertMult;

        //当球拍在较低位置时,且moveY>0.5,则改变球拍向上的速度
        if (moveY > 0.5 && transform.position.y - transform.parent.transform.position.y < -1.5f)
        {
            m_AgentRb.velocity = new Vector3(m_AgentRb.velocity.x, 7f, 0f);
        }
        //改变球拍x(左、右)方向上的速度
        m_AgentRb.velocity = new Vector3(moveX * 30f, m_AgentRb.velocity.y, 0f);
        //改变球拍角度
        m_AgentRb.transform.rotation = Quaternion.Euler(0f, -180f, 55f * rotate + m_InvertMult * 90f);

        //限制球拍向前移动时不要越过网
        if (invertX && transform.position.x - transform.parent.transform.position.x < -m_InvertMult ||
            !invertX && transform.position.x - transform.parent.transform.position.x > -m_InvertMult)
        {
            transform.position = new Vector3(-m_InvertMult + transform.parent.transform.position.x,
                transform.position.y,
                transform.position.z);
        }

        m_TextComponent.text = score.ToString();//计分牌刷新
    }

AgentAction()代码内容也不算太难,主要还是注意对于对抗的两方,通过invertX和m_InvertMult来使得对抗两方同向化。

小小提一下,在最后“限制球拍向前移动时不要越过网”部分,如果以agentA为例,此时invertX=false,m_InvertMult=1,则当满足

(!invertX && transform.position.x - transform.parent.transform.position.x > -m_InvertMult)时,有如下情况:

image-20200410195902358

可以看到,当球拍再向前的话,就会碰到网上,因此需要限制球拍不能越过网。agentB紫色球拍也是一样的情况,只是数值相反。

在这里深入思考一下,为什么只有限制球拍向前移动,而不用去限制球拍向后移动?可能大家也有相应的疑问,其实这里利用两个碰撞体就可以限制球拍的移动了,即网的碰撞体以及场地后方碰撞体:

image-20200410200640970

但是你会发现网的碰撞体只有在球拍在低处时才能限制球拍向前移动,而将代码注释之后,球拍在空中时就会发生:

tennis4

可以看到,你可以控制球拍去对面胖揍对手= =||||。所以这里只用对球拍在自身向前的方向进行限制即可。

Agent手动操控

下面来看一下Heristic()函数:

	/// <summary>
    /// 手动操控
    /// </summary>
    /// <returns></returns>
    public override float[] Heuristic()
    {
        var action = new float[2];

        action[0] = Input.GetAxis("Horizontal");//左右移动控制
        action[1] = Input.GetKey(KeyCode.Space) ? 1f : 0f;//空格使球拍飞起
        return action;
    }

OK,以上代码很简单,但是有一个问题,当你想操作球拍和电脑对打时,将一个球拍的Behavior Type置为Heuristic Only后,开始游戏,你会发现以下错误:

image-20200410201722187

这里是说agent的返回的矢量动作空间数量有问题,如果你比较熟悉ML-Agents之后,你会发现在AgentAction(float[] vectorAction)中,vectorAction[]数组有三个元素,分别控制了球拍的x、y方向移动以及球拍的绕z轴的旋转。而在以上Huristic()代码中,action[]数组只有两个元素,只控制了球拍的x、y方向移动,缺少绕z轴的旋转的行为参数。

因此,这里只需要将AgentAction()方法中的两句代码注释:

var rotate = Mathf.Clamp(vectorAction[2], -1f, 1f) * m_InvertMult;

以及

m_AgentRb.transform.rotation = Quaternion.Euler(0f, -180f, 55f * rotate + m_InvertMult * 90f);

注释后,就可以进行手动操作了,当然如果你也相同时操作球拍旋转,也不是不可以,你可修改手动操控代码如下:

	/// <summary>
    /// 手动操控
    /// </summary>
    /// <returns></returns>
    public override float[] Heuristic()
    {
        var action = new float[3];

        action[0] = Input.GetAxis("Horizontal");//左右移动控制
        action[1] = Input.GetKey(KeyCode.Space) ? 1f : 0f;//空格使球拍飞起
        action[2] = Input.GetAxis("Vertical");//控制球拍旋转
        return action;
    }

五、训练

我们这次训练Tennis的模型,主要包含以下几种:正常训练(不带可变参数)、带两个可变参数(scale、gravity)训练、只带一个可变参数(scale)不注释代码、只带一个可变参数(scale)注释代码。

大家应该还记得我们在上述四、代码分析中的环境初始化脚本一小节,提到在MatchReset()函数中重置小球比例是不合适的,因此我们来验证一下这个想法是否正确。

接下来,我们先进行一组正常训练;然后在利用两个可变参数训练之前,先验证MatchReset()中设置小球比例会不会使得小球比例的可变参数设置失效;最后,我们再进行带两个可变参数的训练。

普通训练(不带可变参数)

我们先来普通训练一次,之前已经重复过很多次的操作~我们cd到ml-agent的目录,然后输入以下命令(当然这里面有一些配置文件、训练结果的路径,可以自行修改):

mlagents-learn config/trainer_config.yaml --run-id=Tennis_Normal --train

因为在训练配置文件trainer_config.yaml中可以看到Tennis的max_steps为5.0e7,即五千万步,是所有例子中训练最大步数最大的。而之前我们的3D Ball才五十万步,前者是后者的100倍,所以训练时间相当长。

实际我训练过程中,大概到100万步的时候的效果就很不错了,因此我将Tennis的max_steps改成了5.0e6。

此次训练次数较多,训练时间比较长,放一张训练的过程截图:

tennis7

可以大概看到两边打的有来有回,同时可以在屏幕下方看到双方的得分。

同时观察命令行输出:

image-20200411000943322

在之前训练中,Mean RewardStd of Reward是衡量训练效果的很重要的两个标准,一般来讲是逐渐上升的,而这里一直是0和1,相应的有两个其他的参数代替:Mean Opponent ELOStd Opponent ELO

经过查阅资料,首先了解一下ELO是什么:ELO等级分制度是指由匈牙利裔美国物理学家Elo创建的一个衡量各类对弈活动水平的评价方法,是当今对弈水平评估的公认的权威方法。

其实我们用简单的话来讲,例如早先英雄联盟有排位分,你的排位分就是利用ELO计算出来的。如果你赢了比你分数更高的对手,你的排位分就会增加更多;如果输给比你分数更少的对少,那你排位分就会减少的更多。更详细的计算方法大家可以去查资料,这个知识点还挺有意思的,可以了解排位分大概是怎么算出来的。

通过对ELO的了解,也可以发现,这里因为用到了对抗训练,所以采用Mean Opponent ELOStd Opponent ELO来看训练的效果,其中我们会发现命令行中有一行是Tennis?team=1 ELO:1615.145,这里其实就代表了team为1的agent(即球拍B),现在它的ELO(可以简单理解为排位分)是1615.145,你会发现它的ELO会随着训练的进行逐渐升高,相当于一直打排位,训练自己上王者。

【Warning】等待了大约48分钟,训练到大概三百万步时,突然发现如下情况:两个球拍不对打了,罢工了!大概的情形就是两个球拍一开始就一起移动到网前,让球直接落地,然后立马开始新的一局,可以之后的截图。此时立马Ctrl+C停止训练。我们可以看一下tensorboard的情况:

image-20200411003841174

首先是对抗训练中会存在ELO图表,其实你就可以看成是排位分的变化趋势,可以明显观察ELO在大概在3百万步时突然下跌,包括Reawd也是,在3百万步处有异常数据。将训练到一半的Tennis_Normal.nn文件放到Unity中:

tennis8

发现两个球拍在一开始就往网前跑,给对手送分。看来利用Ctrl+C还是没能将上一次的训练模型拯救下来= =。

这里我怀疑是这样的,因为在代码中,没有设计在训练过程中,如果对抗双方在一局里长时间来回打球,而给双方同时奖励的机制。当接近3百万时,双方的球技都挺好了,能打的有来有回的时间越来越长,导致长时间Brain没有收到奖励,使得Brain发现如果一直打可能一直都不能得分,自己的排位也上不去,但是如果丢球,还有机会拿奖励分,致使Brain产生消极比赛送分的决策= =||||,这里也是很逗了。所以我将tennis的训练最大步骤设置为2.5e6差不多到达此时情况下ELO的最大值,正所谓是实践出真知啊。

从这也可以看到,训练的最大步数并不是越大越好,还要基于你考虑的是否周全。而且在训练前,应该竟可能设置好矢量观测空间以及其他条件,运行一段时间后及时查看训练效果,不要训练太长时间发现没有效果还硬着头皮训练,也不要一开始训练只训练较少步数没有效果就换参数。

再次训练一次最大训练步数为2.5e6的:

tennis9

会发现训练出来的模型还是有问题,然后我继续调整最大训练步数为2.0e6,对比一下tensorboard:

image-20200411081316991

我们从图表里发现,2.5e6s的训练模型在大概2.1M步骤的时候Brian又罢工了,训练的随机性还是比较大,在最大训练步骤5.0e7时明明在3M左右才开始罢工。。。。

我们把2.0e6的训练模型放到Unity中去,训练效果如下:

tennis10

其实还蛮奇怪的,从训练效果来看,2.0e6是比5.0e7及2.5e6正常一些,但是如果训练步骤太长又会出现Brain罢工的情况,所以我也比较好奇官方Tennis给出的训练模型是如何训练出来的,是不是有一些什么设置我们漏了。这里要是有人知道的话也可以留言讨论交流~下面我们还是继续别的训练。

可变参数设置

以前的文章里我们称可变参数为泛化参数,是因为ml-agents官方改了,因此我们以后就把“泛化参数”统一称作“可变参数”。可变参数我们这次按官方推荐的来设置,先来看一下Tennis的可变参数配置:

tennis_generalize.yaml

resampling-interval: 20000

scale:
    sampler-type: "uniform"
    min_value: 0.2
    max_value: 5
    
gravity:
    sampler-type: "uniform"
    min_value: 6
    max_value: 20

注意gravity参数的设置是在ProjectSettingOverrides.cs脚本里的:Academy.Instance.FloatProperties.RegisterCallback("gravity", f => { Physics.gravity = new Vector3(0, -f, 0); });来设置的。

此外,我们将resampling-interval设置为20000,是由于在trainer_config.yaml配置文件中,Tennis的训练步骤我们设置的是2.0e6步,参照3D Ball,5.0e5次,可变训练间隔为5000,依此参照,设置Tennis的这个参数为20000。当然这样参照设置不一定适合,在训练过程中如果想将某参数改变引入可变,那么在改变前的参数对应的训练效果应该是基本成型的,如果训练还么有成型就改变参数,有可能造成因为一直改参数使得训练效果一直不佳。就和做软件一样,如果软件需求一直更改,那么等到软件成型出产品基本就等到猴年马月了。

一个可变参数训练

为了验证MatchReset()中是否需要设置小球比例的问题,我们新增可变参数配置文件:

tennis_generalize_1.yaml

resampling-interval: 20000

scale:
    sampler-type: "uniform"
    min_value: 0.2
    max_value: 5

将gravity参数去掉,用来消除gravity改变带来的影响。我们先试验注释代码后的效果,在命令行中输入:

mlagents-learn config/trainer_config.yaml --sampler=config/tennis_generalize_1.yaml --run-id=Tennis_Gen_1_DeleteScale --train

训练截图如下:

tennis11

可以明显看到一开始球的大小有改变。训练结束后,再将代码取消注释,命令行中输入如下命令进行训练:

mlagents-learn config/trainer_config.yaml --sampler=config/tennis_generalize_1.yaml --run-id=Tennis_Gen_1_ModifyScale --train

会发现小球的大小在训练过程中,有时候一开始会变大或者变小,但是会立马变为正常大小进行训练,这也符合我的想法,即MatchReset()会在SetResetParameters()之后将小球的比例复位成0.5。在相同训练配置下,代码注释后的训练相比代码注释前的训练,会使得小球比例改变,从而也就证明了MatchReset()中设置小球比例在可变参数训练中是不应该的的。

两个可变参数训练

我们利用两个可变参数的配置文件,输入以下命令(当然这里面有一些配置文件、训练结果的路径,可以自行修改):

mlagents-learn config/trainer_config.yaml --sampler=config/tennis_generalize.yaml --run-id=Tennis_Gen --train

等待训练完成后,查看tensorboard图表:

image-20200411165831643

可以看出,与不带可变参数的训练,带可变参数的训练其中还是有一些波折的,波折基本就代表了参数改变使得agent的ELO下降,这里的训练数据仅仅作为一个参考吧。把训练模型放到Unity中效果依然不是很好,这里就不展示了。

总结

这次的Tennis示例研究,主要时间都消耗到训练上,最终也没有训练出和官方一样的效果,这点还是有点遗憾,我认为应该是奖励规则有漏洞的原因,具体在第五节已经写了,当然也有可能是我哪里设置错了。不过在这个过程中已经有挺多经验值得学习和记录了,过程还是比较有意思,因此这个示例的研究先到这,说不定后面研究更多的示例就会豁然开朗了。

写文不易~因此做以下申明:

1.博客中标注原创的文章,版权归原作者 煦阳(本博博主) 所有;

2.未经原作者允许不得转载本文内容,否则将视为侵权;

3.转载或者引用本文内容请注明来源及原作者;

4.对于不遵守此声明或者其他违法使用本文内容者,本人依法保留追究权等。

posted @ 2020-04-11 17:11  煦阳  阅读(2668)  评论(10编辑  收藏  举报