目录
一, Tennis 介绍
二, 环境与训练参数
三, 场景基本结构
四, 代码分析
环境初始化脚本
Agent 脚本
Agent 初始化与重置
矢量观测空间
Agent 动作反馈
Agent 手动操控
五, 训练
普通训练(不带可变参数)
可变参数设置
一个可变参数训练
两个可变参数训练
总结
这次 Tennis 示例研究费了我不少劲, 倒不是因为示例的难度有多大, 而重点是这个示例的训练过程中遇到了许多问题值得记录下来, 其次这个训练是一个对抗训练, 也是比较有意思的示例.
一, Tennis 介绍
首先来看看效果~
OK, 可以看到画面中有 18 个网球场, 然后蓝色的球拍和紫色的球拍互相对打. 这里注意一下, 场景虽然都是 3D 的, 但实际上球拍和球只在球场的中轴线上上下左右移动, 也就是说其实换个相机位置的话, 这里其实是个二维打球模拟.
当然了, 这样算是简化了训练的过程, 这个示例大部分所用到的内容和 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 个球场, 如下图:
相应的 Hierarchy 层级:
场景中 Camera,Canvas 啥的都不用太多去介绍, TennisSettings 可以设置环境一些高级物理设置, 例如 Unity 中的 Physics.gravity,Time.fixedDeltaTime,Physics.defaultSolverIterations 等.
TennisArea 是作为一个训练的基本单位. 其中:
TennisArea
父物体上本来就带有一个 TennisArea.cs 脚本, 这个脚本主要是重置比赛环境, 例如每次开始球的位置, 比例等, 但是我认为在这里初始化球的比例有问题, 这个我们后面说.
Ball
球, 带有刚体, 存在 HitWall.cs 脚本, 你会发现有意思的是, 原来我们看的示例 agent 的奖惩基本都在 AgentAction()函数中, 即随时判断 agent 是否奖惩, 但是这里将 agent 奖励或失败设置放到了球上, 由球来决定到底是 agentA 赢还是 agentB 赢, 然后重置 agent. 当然这也比较好理解, 因为规则都在球上, 球当然知道到底谁赢了(感觉有点邪恶....2333).
Invisible Walls
如英文注释, 就是透明碰撞体, 场地两边地面各一个, 场后各一个, 就是为了防止球掉落. 当然我想吐槽一下这个碰撞体, 设置的太粗旷了 = =, 两边的墙巨长无比.
Scenery
球场四周的碰撞体, 网的碰撞体以及网上的碰撞体.
AgentA 和 AgentB
两个 agent, 分别代表两对抗方, 公用一套训练参数进行训练.
这里两个 agent 大家注意, 看一下有啥不一样, 首先看 Behavior Parameters 组件:
可以看到两个球拍的行为参数, Team Id 是不同的, 表示了两个不同的阵营, 猜测如果有多方阵营也可以训练, 还有这里应该是使用 Behavior Name 来保证训练的 Brain 一致, 在前几面的文章中也有提及.
再来看一下两个球拍的 Tennis Agent:
会发现一个 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, 当然我们可以把这里注释掉, 看看是什么效果.
这里会发现球的速度过快, 下面的 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, 以上代码其实大部分都很简单, 但是关于两个变量 invertX 和 m_InvertMult, 我给它们分别取名为 "镜像标志位" 和 "镜像翻转乘数". 从名字上也可以看出些许猫腻, 简单来讲, invertX 区分了两个球拍, 且决定了 m_InvertMult 的值是 1 还是 - 1, 而 m_InvertMult 则是一个使得两个对立的球拍方向统一化的乘数, 这样就可以使得两个球拍虽然是对手, 但是经过镜像翻转乘数, 实际上转换后, 可以看成两个球拍都是向同一个方向训练.
当然在球拍位置初始化, 角度初始化时, m_InvertMult 也有作用, 如下图:
可以看到两个球拍的 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);
- }
总体来说, 这里收集了球拍和场地, 球和场地的相对位置以及它们各自的速度信息, 这里再利用图来讲一下镜像翻转乘数.
这样通过 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)时, 有如下情况:
可以看到, 当球拍再向前的话, 就会碰到网上, 因此需要限制球拍不能越过网. agentB 紫色球拍也是一样的情况, 只是数值相反.
在这里深入思考一下, 为什么只有限制球拍向前移动, 而不用去限制球拍向后移动? 可能大家也有相应的疑问, 其实这里利用两个碰撞体就可以限制球拍的移动了, 即网的碰撞体以及场地后方碰撞体:
但是你会发现网的碰撞体只有在球拍在低处时才能限制球拍向前移动, 而将代码注释之后, 球拍在空中时就会发生:
可以看到, 你可以控制球拍去对面胖揍对手 = =||||. 所以这里只用对球拍在自身向前的方向进行限制即可.
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 后, 开始游戏, 你会发现以下错误:
这里是说 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.
此次训练次数较多, 训练时间比较长, 放一张训练的过程截图:
可以大概看到两边打的有来有回, 同时可以在屏幕下方看到双方的得分.
同时观察命令行输出:
在之前训练中, Mean Reward 和 Std of Reward 是衡量训练效果的很重要的两个标准, 一般来讲是逐渐上升的, 而这里一直是 0 和 1, 相应的有两个其他的参数代替: Mean Opponent ELO 和 Std Opponent ELO.
经过查阅资料, 首先了解一下 ELO 是什么: ELO 等级分制度是指由匈牙利裔美国物理学家 Elo 创建的一个衡量各类对弈活动水平的评价方法, 是当今对弈水平评估的公认的权威方法.
其实我们用简单的话来讲, 例如早先英雄联盟有排位分, 你的排位分就是利用 ELO 计算出来的. 如果你赢了比你分数更高的对手, 你的排位分就会增加更多; 如果输给比你分数更少的对少, 那你排位分就会减少的更多. 更详细的计算方法大家可以去查资料, 这个知识点还挺有意思的, 可以了解排位分大概是怎么算出来的.
通过对 ELO 的了解, 也可以发现, 这里因为用到了对抗训练, 所以采用 Mean Opponent ELO 和 Std Opponent ELO 来看训练的效果, 其中我们会发现命令行中有一行是 Tennis?team=1 ELO:1615.145, 这里其实就代表了 team 为 1 的 agent(即球拍 B), 现在它的 ELO(可以简单理解为排位分)是 1615.145, 你会发现它的 ELO 会随着训练的进行逐渐升高, 相当于一直打排位, 训练自己上王者.
[Warning] 等待了大约 48 分钟, 训练到大概三百万步时, 突然发现如下情况: 两个球拍不对打了, 罢工了! 大概的情形就是两个球拍一开始就一起移动到网前, 让球直接落地, 然后立马开始新的一局, 可以之后的截图. 此时立马 Ctrl+C 停止训练. 我们可以看一下 tensorboard 的情况:
首先是对抗训练中会存在 ELO 图表, 其实你就可以看成是排位分的变化趋势, 可以明显观察 ELO 在大概在 3 百万步时突然下跌, 包括 Reawd 也是, 在 3 百万步处有异常数据. 将训练到一半的 Tennis_Normal.nn 文件放到 Unity 中:
发现两个球拍在一开始就往网前跑, 给对手送分. 看来利用 Ctrl+C 还是没能将上一次的训练模型拯救下来 = =.
这里我怀疑是这样的, 因为在代码中, 没有设计在训练过程中, 如果对抗双方在一局里长时间来回打球, 而给双方同时奖励的机制. 当接近 3 百万时, 双方的球技都挺好了, 能打的有来有回的时间越来越长, 导致长时间 Brain 没有收到奖励, 使得 Brain 发现如果一直打可能一直都不能得分, 自己的排位也上不去, 但是如果丢球, 还有机会拿奖励分, 致使 Brain 产生消极比赛送分的决策 = =||||, 这里也是很逗了. 所以我将 tennis 的训练最大步骤设置为 2.5e6 差不多到达此时情况下 ELO 的最大值, 正所谓是实践出真知啊.
从这也可以看到, 训练的最大步数并不是越大越好, 还要基于你考虑的是否周全. 而且在训练前, 应该竟可能设置好矢量观测空间以及其他条件, 运行一段时间后及时查看训练效果, 不要训练太长时间发现没有效果还硬着头皮训练, 也不要一开始训练只训练较少步数没有效果就换参数.
再次训练一次最大训练步数为 2.5e6 的:
会发现训练出来的模型还是有问题, 然后我继续调整最大训练步数为 2.0e6, 对比一下 tensorboard:
我们从图表里发现, 2.5e6s 的训练模型在大概 2.1M 步骤的时候 Brian 又罢工了, 训练的随机性还是比较大, 在最大训练步骤 5.0e7 时明明在 3M 左右才开始罢工....
我们把 2.0e6 的训练模型放到 Unity 中去, 训练效果如下:
其实还蛮奇怪的, 从训练效果来看, 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
训练截图如下:
可以明显看到一开始球的大小有改变. 训练结束后, 再将代码取消注释, 命令行中输入如下命令进行训练:
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 图表:
可以看出, 与不带可变参数的训练, 带可变参数的训练其中还是有一些波折的, 波折基本就代表了参数改变使得 agent 的 ELO 下降, 这里的训练数据仅仅作为一个参考吧. 把训练模型放到 Unity 中效果依然不是很好, 这里就不展示了.
总结
这次的 Tennis 示例研究, 主要时间都消耗到训练上, 最终也没有训练出和官方一样的效果, 这点还是有点遗憾, 我认为应该是奖励规则有漏洞的原因, 具体在第五节已经写了, 当然也有可能是我哪里设置错了. 不过在这个过程中已经有挺多经验值得学习和记录了, 过程还是比较有意思, 因此这个示例的研究先到这, 说不定后面研究更多的示例就会豁然开朗了.
写文不易~ 因此做以下申明:
1. 博客中标注原创的文章, 版权归原作者 煦阳(本博博主) 所有;
2. 未经原作者允许不得转载本文内容, 否则将视为侵权;
来源: https://www.cnblogs.com/gentlesunshine/p/12681019.html