本案例源自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案例之地牢逃脱
ML-Agents案例之金字塔
ML-Agents案例之蠕虫
ML-Agents案例之机器人学走路
ML-Agents案例之看图配对
如图所示,智能体在一个圆形的房间中,墙壁上会随机出现带有数字的方块,智能体需要按照数字从小到大与方块进行碰撞,碰撞过的方块会变成绿色,分数+1,一旦碰撞顺序不对,游戏结束,分数-1。
这个案例的挑战是,我们不会告诉智能体怎么排序是对的,智能体需要在环境中试错,从而自己学习到这种从小到大排序,碰撞对应方块的行为模式,同时墙壁上出现的数字方块的个数是不定的,也就是说每个episode我们都需要接收不同个数的输入,这应该怎么处理呢?
状态输入:这里用到了一个新的传感器Buffer Sensor。
这个传感器的作用是可以接收个数变化的状态输入。我们需要每次传入一个向量,这个向量我们可以用数组listObservation表示。通过 m_BufferSensor.AppendObservation(listObservation)传入到BufferSensor中,而BufferSensor可以接收无数个这样的向量输入,但是每个向量的维度必须相同。也就是说即使我们输入的向量个数每次都不同,我们还是能训练网络还是产生我们所期望的输出,具体是怎么实现的项目代码中没有,集成在了ML-Agents包中,根据我的经验,应该用了Self-attention这种网络的结构,这样就能接收不同个数向量的输入了。
除了传给BufferSensor的输入之外,还传入了四维的向量,分别是智能体位置到场地中心的向量在x轴和z轴上的分量,智能体前进方向在x轴和z轴的分量。
动作输出:输出三个离散值,每个离散值包含0-2三个数,第一个离散值决定了前进后退,第二个离散值决定了左移右移,第三个离散值决定了左转右转。
智能体下挂载的脚本除去万年不变的Decesion Requester,Model Overrider,Behavior Parameters,以及刚刚说明的Buffer Sensor,就只剩下智能体的只有文件SorterAgent.cs了:
头文件:
using System.Collections.Generic;
using UnityEngine;
using Unity.MLAgents;
using Unity.MLAgents.Actuators;
using Unity.MLAgents.Sensors;
using Random = UnityEngine.Random;
定义变量:
// 默认数字方块的最大数量,可在编辑器中滑动调节,调节范围为1 - 20
[Range(1, 20)]
public int DefaultMaxNumTiles;
// 方块数字的最大值
private const int k_HighestTileValue = 20;
// 生成方块的数量
int m_NumberOfTilesToSpawn;
// 方块的最大数量
int m_MaxNumberOfTiles;
// 刚体
Rigidbody m_AgentRb;
// BufferSensorComponent 是一个传感器,允许观察不同数量的输入
BufferSensorComponent m_BufferSensor;
// 数字方块的列表
public List<NumberTile> NumberTilesList = new List<NumberTile>();
// 出现在场景中的方块列表
private List<NumberTile> CurrentlyVisibleTilesList = new List<NumberTile>();
// 已经被接触过的方块列表
private List<Transform> AlreadyTouchedList = new List<Transform>();
private List<int> m_UsedPositionsList = new List<int>();
// 初始位置
private Vector3 m_StartingPos;
// 整个场景
GameObject m_Area;
// 环境参数,可以从配置文件中获取
EnvironmentParameters m_ResetParams;
// 下一个想要碰撞的数字方块的索引
private int m_NextExpectedTileIndex;
初始化方法Initialize():
public override void Initialize()
{
// 获取父物体
m_Area = transform.parent.gameObject;
// 获取方块的最大数量
m_MaxNumberOfTiles = k_HighestTileValue;
// 从配置文件中获取环境参数
m_ResetParams = Academy.Instance.EnvironmentParameters;
// 获取传感器脚本
m_BufferSensor = GetComponent<BufferSensorComponent>();
// 获取刚体
m_AgentRb = GetComponent<Rigidbody>();
// 起始位置
m_StartingPos = transform.position;
}
状态输入方法:
public override void CollectObservations(VectorSensor sensor)
{
// 获取智能体到场地中心的x轴和z轴上的距离
sensor.AddObservation((transform.position.x - m_Area.transform.position.x) / 20f);
sensor.AddObservation((transform.position.z - m_Area.transform.position.z) / 20f);
// 获取智能体前进方向的x轴和z轴的值
sensor.AddObservation(transform.forward.x);
sensor.AddObservation(transform.forward.z);
foreach (var item in CurrentlyVisibleTilesList)
{
// 定义一个数组,存放一系列观察值,数组长度为数字方块最大数量 + 3,默认初始化全部为0
float[] listObservation = new float[k_HighestTileValue + 3];
// 获取方块的数字,设置对应的one-hot向量
listObservation[item.NumberValue] = 1.0f;
// 获取方块的坐标(子物体坐标才是真实坐标的,transform本身的位置保持在场景中央,方便旋转)
var tileTransform = item.transform.GetChild(1);
// 输入数字方块和智能体的x分量和z分量
listObservation[k_HighestTileValue] = (tileTransform.position.x - transform.position.x) / 20f;
listObservation[k_HighestTileValue + 1] = (tileTransform.position.z - transform.position.z) / 20f;
// 该方块是否已经被碰撞过
listObservation[k_HighestTileValue + 2] = item.IsVisited ? 1.0f : 0.0f;
// 把数组添加到Buffer Sensor中(不直接输入到网络的原因是需要添加的数组个数个数是变化的)
m_BufferSensor.AppendObservation(listObservation);
}
}
动作输出方法OnActionReceived:
public override void OnActionReceived(ActionBuffers actionBuffers)
{
// 移动智能体
MoveAgent(actionBuffers.DiscreteActions);
// 时间惩罚,激励智能体越快完成越好
AddReward(-1f / MaxStep);
}
public void MoveAgent(ActionSegment<int> act)
{
var dirToGo = Vector3.zero;
var rotateDir = Vector3.zero;
// 获取神经网络三个离散输出
var forwardAxis = act[0];
var rightAxis = act[1];
var rotateAxis = act[2];
// 第一个离散输出决定了前进后退
switch (forwardAxis)
{
case 1:
dirToGo = transform.forward * 1f;
break;
case 2:
dirToGo = transform.forward * -1f;
break;
}
// 第二个离散输出决定了左移右移
switch (rightAxis)
{
case 1:
dirToGo = transform.right * 1f;
break;
case 2:
dirToGo = transform.right * -1f;
break;
}
// 第三个离散输出决定了左转右转
switch (rotateAxis)
{
case 1:
rotateDir = transform.up * -1f;
break;
case 2:
rotateDir = transform.up * 1f;
break;
}
// 执行动作
transform.Rotate(rotateDir, Time.deltaTime * 200f);
m_AgentRb.AddForce(dirToGo * 2, ForceMode.VelocityChange);
}
每一个episode(回合)开始时执行的方法OnEpisodeBegin:
public override void OnEpisodeBegin()
{
// 从配置文件中获取方块的数量,没有的话设为DefaultMaxNumTiles
m_MaxNumberOfTiles = (int)m_ResetParams.GetWithDefault("num_tiles", DefaultMaxNumTiles);
// 随机生成方块的数量
m_NumberOfTilesToSpawn = Random.Range(1, m_MaxNumberOfTiles + 1);
// 选择将要生成的对应的方块并加入列表中
SelectTilesToShow();
// 生成方块及调整位置
SetTilePositions();
transform.position = m_StartingPos;
m_AgentRb.velocity = Vector3.zero;
m_AgentRb.angularVelocity = Vector3.zero;
}
void SelectTilesToShow()
{
// 清除两个列表
CurrentlyVisibleTilesList.Clear();
AlreadyTouchedList.Clear();
// 共生成nunLeft个方块
int numLeft = m_NumberOfTilesToSpawn;
while (numLeft > 0)
{
// 在范围内取随机数生成对应方块
int rndInt = Random.Range(0, k_HighestTileValue);
var tmp = NumberTilesList[rndInt];
// 如果对应的方块列表中没有才进行添加
if (!CurrentlyVisibleTilesList.Contains(tmp))
{
CurrentlyVisibleTilesList.Add(tmp);
numLeft--;
}
}
// 给方块列表列表按照数字升序进行排序
CurrentlyVisibleTilesList.Sort((x, y) => x.NumberValue.CompareTo(y.NumberValue));
m_NextExpectedTileIndex = 0;
}
void SetTilePositions()
{
// 清空列表
m_UsedPositionsList.Clear();
// 重置所有方块的状态,ResetTile方法可以在数字方块的脚本中看到
foreach (var item in NumberTilesList)
{
item.ResetTile();
item.gameObject.SetActive(false);
}
foreach (var item in CurrentlyVisibleTilesList)
{
bool posChosen = false;
// rndPosIndx决定了我们方块的旋转角度(即在圆形场地的哪里)
int rndPosIndx = 0;
while (!posChosen)
{
rndPosIndx = Random.Range(0, k_HighestTileValue);
// 这个旋转角度是否被选了,没被选就加入列表中
if (!m_UsedPositionsList.Contains(rndPosIndx))
{
m_UsedPositionsList.Add(rndPosIndx);
posChosen = true;
}
}
// 执行方块角度的旋转并激活物体
item.transform.localRotation = Quaternion.Euler(0, rndPosIndx * (360f / k_HighestTileValue), 0);
item.gameObject.SetActive(true);
}
}
当与别的物体开始发生碰撞执行方法OnCollisionEnter:
private void OnCollisionEnter(Collision col)
{
// 只检测和数字方块的碰撞
if (!col.gameObject.CompareTag("tile"))
{
return;
}
// 如果方块已经碰撞过,也排除在碰撞对象之外
if (AlreadyTouchedList.Contains(col.transform))
{
return;
}
// 如果碰撞的顺序错误,奖励-1,结束游戏
if (col.transform.parent != CurrentlyVisibleTilesList[m_NextExpectedTileIndex].transform)
{
AddReward(-1);
EndEpisode();
}
// 碰撞到正确的方块的情况
else
{
// 奖励+1
AddReward(1);
// 改变方块的材质
var tile = col.gameObject.GetComponentInParent<NumberTile>();
tile.VisitTile();
// 索引+1
m_NextExpectedTileIndex++;
// 把方块加入到已接触列表中
AlreadyTouchedList.Add(col.transform);
// 如果完成了所有的任务,游戏结束
if (m_NextExpectedTileIndex == m_NumberOfTilesToSpawn)
{
EndEpisode();
}
}
}
当智能体没有模型,人想手动录制示例时可以采用Heuristic方法:
public override void Heuristic(in ActionBuffers actionsOut)
{
var discreteActionsOut = actionsOut.DiscreteActions;
//forward
if (Input.GetKey(KeyCode.W))
{
discreteActionsOut[0] = 1;
}
if (Input.GetKey(KeyCode.S))
{
discreteActionsOut[0] = 2;
}
//rotate
if (Input.GetKey(KeyCode.A))
{
discreteActionsOut[2] = 1;
}
if (Input.GetKey(KeyCode.D))
{
discreteActionsOut[2] = 2;
}
//right
if (Input.GetKey(KeyCode.E))
{
discreteActionsOut[1] = 1;
}
if (Input.GetKey(KeyCode.Q))
{
discreteActionsOut[1] = 2;
}
}
挂载在数字方块上的脚本NumberTile.cs:
using UnityEngine;
public class NumberTile : MonoBehaviour
{
// 方块上的数字
public int NumberValue;
// 默认材质和成功时转换用的材质
public Material DefaultMaterial;
public Material SuccessMaterial;
// 是否已经碰撞过
private bool m_Visited;
// 渲染,用于转换材质
private MeshRenderer m_Renderer;
public bool IsVisited
{
get { return m_Visited; }
}
// 用于转换材质的方法
public void VisitTile()
{
m_Renderer.sharedMaterial = SuccessMaterial;
m_Visited = true;
}
// 重置方块的方法,材质还原,m_Visited状态还原
public void ResetTile()
{
if (m_Renderer is null)
{
m_Renderer = GetComponentInChildren<MeshRenderer>();
}
m_Renderer.sharedMaterial = DefaultMaterial;
m_Visited = false;
}
}
behaviors:
Sorter:
trainer_type: ppo
hyperparameters:
batch_size: 512
buffer_size: 40960
learning_rate: 0.0003
beta: 0.005
epsilon: 0.2
lambd: 0.95
num_epoch: 3
learning_rate_schedule: constant
network_settings:
normalize: False
hidden_units: 128
num_layers: 2
vis_encode_type: simple
reward_signals:
extrinsic:
gamma: 0.99
strength: 1.0
keep_checkpoints: 5
max_steps: 5000000
time_horizon: 256
summary_freq: 10000
environment_parameters:
num_tiles:
curriculum:
- name: Lesson0 # The '-' is important as this is a list
completion_criteria:
measure: progress
behavior: Sorter
signal_smoothing: true
min_lesson_length: 100
threshold: 0.3
value: 2.0
- name: Lesson1
completion_criteria:
measure: progress
behavior: Sorter
signal_smoothing: true
min_lesson_length: 100
threshold: 0.4
value: 4.0
- name: Lesson2
completion_criteria:
measure: progress
behavior: Sorter
signal_smoothing: true
min_lesson_length: 100
threshold: 0.45
value: 6.0
- name: Lesson3
completion_criteria:
measure: progress
behavior: Sorter
signal_smoothing: true
min_lesson_length: 100
threshold: 0.5
value: 8.0
- name: Lesson4
completion_criteria:
measure: progress
behavior: Sorter
signal_smoothing: true
min_lesson_length: 100
threshold: 0.55
value: 10.0
- name: Lesson5
completion_criteria:
measure: progress
behavior: Sorter
signal_smoothing: true
min_lesson_length: 100
threshold: 0.6
value: 12.0
- name: Lesson6
completion_criteria:
measure: progress
behavior: Sorter
signal_smoothing: true
min_lesson_length: 100
threshold: 0.65
value: 14.0
- name: Lesson7
completion_criteria:
measure: progress
behavior: Sorter
signal_smoothing: true
min_lesson_length: 100
threshold: 0.7
value: 16.0
- name: Lesson8
completion_criteria:
measure: progress
behavior: Sorter
signal_smoothing: true
min_lesson_length: 100
threshold: 0.75
value: 18.0
- name: Lesson9
value: 20.0
可以看到配置文件采用了最为常用的PPO算法,而且是没有带其他“配件”例如LSTM,内在奖励机制等模块的普通PPO,唯一的不同是这里加入了Curriculum Learning(课程学习),也就是说,这种能够数十个方块的排序的智能体是很难一下子训练出来的,因此我们需要从易到难给它安排任务,从一开始能排序两个方块逐渐两个两个递增,最后达到20个。关于Curriculum Learning有关参数的详细解释,请查看我前面的文章ML-Agents案例之跳墙游戏。
本案例相比于之前的案例的创新点在于引入了Buffer Sensor,这个传感器是用于接收不同个数向量的输入的,而并非像以往的传感器一样挂在智能体下就能用,这是为了处理类似该案例情况下接收信息数量随环境改变的情况的,这种情况有很多,例如智能体在面对敌人时,敌人的个数是不确定的,敌人发射子弹的数量也是不确定的,这时候,我们就需要用到Buffer Sensor,用来接受不同个数的输入,当然这样的训练往往也需要更多的样本,各种数量的输入都需要覆盖到,否则就会过拟合。为了达到这个目的,这里用到了之前的Curriculum Learning(课程学习)来使训练样本多样化,同时使得训练从易到难,使得智能体的策略具有鲁棒性。