神经网络进阶(连载6) 实时演化

来源:互联网 发布:淘宝山鸡批发 编辑:程序博客网 时间:2024/06/15 13:53

游戏编程中的人工智能技术
    

   

 (连载六)

 

 实时演化

(Real-Time Evolution)

 

    一旦我们开始了编程,我们吃惊地发现,要使程序正确并不像我们想象的那么容易。程序必须经过调试才能发现各种各样错误。我能确切记得,我当时意识到了,从此之后我将要为在自己的程序中查找错误而花费我生命中一大部分时间。

---Maurice Wilkes 发现debugging(调试),l949  

 

到此你已经知道了怎样通过一代一代的改进过程去演化行为。这种方法适合于:允许你用非在线的方式,或者,可以利用游戏的自然停等期间(如在进行升级时),来改进你的游戏代理的行为。在这样的一些情况下,这种方法都是很适宜的。但现在我要介绍的另外一种简单方法是,它允许你正在玩游戏的时候来演化基因组。这种方法适合于:你有很多很多的游戏代理,它们经常是一些在毁灭,另一些在诞生出来。这种演化类型的优点是,它能适用于接纳各种动态的游戏对象,如不同人的游戏玩家,或者切换到了可能不会遇到离线的游戏环境。例如,在你喜爱的实时战斗游戏中,你的坦克有可能要根据它的对手的游戏方式来立即改变自己的行为。

我将要介绍的技术实际只是实现过程中的一个小技巧。简单的说,当需要进行在线演化群体时,你要做的全部工作,就是设置一个基因组池(pool),来保存群体中所有的基因组,且使它们始终保持有序。游戏中使用的个体就是从这一个池中孵化出来的。当游戏时某一个体被杀死之后,就立刻会有另一个替身从池中孵化出来。替身是由群体中性能较好的一些分子中(例如从分数最高的20%中)选出经过变异得到。利用这样的演化方式,群体不会出现一代一代演化时所出现的波浪式变化,而是不断发生着经常性的个体生、死周期。由于这一原因,为了正常工作,这一技术需要游戏代理的快速更替。如果你的游戏代理按太低的比率死去,则演化不大可能获得令人满意的步伐。但如果你的游戏代理能像打杀类游戏(shoot-em-up)或某些实时军事类游戏那样,实现快速更替,则利用本演化方法就可以获得很好的效果。

 

10.1聪明的外星人(Brainy Aliens)

为了例证上面的原理,下面我来向你介绍,上面这样的技术如何能应用到类似于太空入侵者(Space Invader)中外星人动作的演化。


图10.1  聪明的外星人在行动

我已把例子进行了简化。屏幕中只有一帮外星人在飞行,另外就是他们的敌人 - 你。外星人必须学习怎样尽可能长地生存着而不被你杀死。如果他们被你击中或者飞离屏幕的顶或底部那么都要死去。他们活得愈长,他们的适应性分数也愈高。

程序使用了2个外星人收容所。第一个是std::multiset,这是一个保存外星人的有序池,第二个是std::vector,它用来保存当前活跃于游戏中的入侵者。见图10.2。


图10.2  实时演化

   当一外星人死去时,他就从游戏的画面中移走;如果他的适应性分数高于群体中表现最差者的分数,则他的基因组就被加入基因组池中,而他在游戏中位置就由迄今为止表现较好的分子经过突变得到的一个外星人所代替。

 

10.1.1程序实现

你可以在光盘“Chapter10/Brainy Aliens”文件夹中找到本工程的源码。程序中有关游戏对象的类共有三种:CGun(枪类),CBullet(子弹类)和CAlien(外星人类)。此外同样还包含通常都会有的神经网络类CNeuralNet和控制器类CController。CGun和CBullet类的结构很简单,利用代码中所加的注释你就能完全看懂。但为了使你能正确地理解每一桩事情是怎样工作的,我必须更详细地描述一下CAlien和Ccontroller两个类的结构。首先让我来告诉你怎样控制外星人。

 

10.1.1.1 Roswell再现了:外星人大脑的尸体解剖

在我描述外星人头脑的内部工作以前,快速察看一下外星人类CAlien的定义。

  class CAlien

    {

 

private:

 

CNeuralNet       m_ItsBrain;

 

    //它(外星人)在世界(坐标系)中的位置和速度

SVector2D         m_vPos;

 

SVector2D    m_vVelocity;

 

   //它的大小

    double            m_dScale;

    

    //它的质量

double           m_dMass;

 

//它的年龄(=它的适应性分)

int           m_iAge;

 

//它的最小包围矩形(bouding box,供碰撞检测用)

RECT          m_AlienBBox;

 

     //外星人局部坐标的顶点缓冲区

    vector<SPoint>    m_vecAlienVB;

    

    //存放外星人变换后顶点坐标的顶点缓冲区

vector<SPoint>  m_vecAlienVBTrans;

 

    //当设置为true时,一个warning(警告)会被显示出来,通知你有一个

    //输入的大小与神经网络不匹配。

bool       m_bWarning;

 

void       WorldTransform();

 

//检查是否和任何活动着的子弹相撞了?如果检测到相撞,返回true值

bool       CheckForCollision(vector<CBullet> &bullets)const;

 

   //更新外星人的神经网络,并且返回它的下一个行动

action-type   GetActionFromNetWork

(constvector<CBullet> &bullets, const SVector2D  &GunPos);

 

   //重载‘<’,用于排序

    friend bool operator<(const CAlien& lhs, const CAlien& rhs)

     {

       return (lhs.m_iAge > rhs.m_iAge);

}

 

    public:

 

    CAlien();

  void Render(HDC &surface, HPEN&GreenPen, HPEN &RedPen);

 

  //查询外星人大脑,并相应地更新它位置

     bool Update(vector<CBullet>&bullets, const SVector2D &GunPos);

 

    //复位任何有关的成员变量,准备进行一次新的运行

    void Reset();

    

    //将外星人神经网络中的连接权重实行突变

    void Mutate();

    

    //------------------------------------访问方法

    SVector2D      Pos()const{return m_vPos;}

    double            Fitness()const{return m_iAge;}

};

 

外星人的大脑结构如图10.3所示那样。缺省时,屏幕上任何时候只有3个子弹。为了检测子弹在什么地方,一个外星人的神经网络有6个(3对)输入,每2个(一对)输入代表由它到子弹的向量。另外,每个神经网络还有2个输入,用来表示从它(外星人)到发射子弹的枪(gun,或架设枪的战车turret)间的向量。如果子弹不活动(没有进入到屏幕上),则神经网络的输入也接受从它引向战车(枪)的向量。

  

 图10.3  在一外星人大脑的内部

外星人具有质量,并受到重力的影响。为了移动,他们能引发出冲刺或推进动作,由此使他们突然向上、向左、或向右移动。外星人在每一帧中都有4种可以选择的动作。这就是

o  向上推进(thrust_up)

o  向左推进(thrust_left)

o  向右推进(thrust_right)

o  漂移 (drift)

一个外星人的神经网络有3个输出,每一个输出都如上面列表中前三种动作之一那样进行切换。要切换到ON,输出必须具有大于0.9的激励输入。如果存在多个的输出超过0.9,则选择分数最高者。如果所有输出处于Off状态,则外星人就受重力作用而下落。

 

四种动作组成了action_type枚举类型,如下:

 

enum action_type{ thrust_left,

        thrust_right,

                  thrust_up,

                    drift};

 

例如,图10.4左边显示的输出将引发thrust_right(向右推进)行动,而图10.4右边显示的输出将引发drift(漂流)动作。


图10.4  外星人的行动的例子 

下面就是用来更新并从外星人大脑接收指令的方法的代码: 

action_type CAlien::GetActionFromNetwork

(constvector<CBullet> &bullets, const SVector2D &GunPos)

   {

     //送进到网络的输入:

       vector<double>NetInputs;

 

    //用来保存从神经网络产生的输出:

      static vector<double>outputs(0,3);

 

    //指向战车位置的向量:

     int XComponentToTurret = GunPos.x- m_vPos.x;

     int YComponentToTurret = GunPos.y- m_vPos.y;

 

     NetInputs.push_back(XComponentToTurret);

 

     NetInputs.push_back(YComponentToTurret);

 

     //对任意一个子弹进行操作:

     for (int blt=O;blt<bullets.size(); ++blt)

      {

       if(bullets[blt].Active())

         {

         doublexComponent = bullets[blt].Pos().x - m_vPos.x;

         doubleyComponent = bullets[blt].Pos().y - m_vPos.y;

 

         NetInputs.push_back(xComponent);

         NetInputs.push_back(yComponent);

       }

 

    else

      {

         //如果子弹未活动,则输入一个指向枪台(战车)的向量

         NetInputs.push_back(XComponentToTurret);

         RetInputs.push_back(YComponentToTurret);

      }

   }

 

    //将输入送进网络并获取输出

      outputs =m_ItsBrain.Update(NetInputs);

 

    //如果更新中出现了任何问题,则将其置为true

     if (outputs.size() == 0)

       {

        m_bNWarning = true;

       }

 

    //确定本帧中最高值输出超过0.9的那个动作为有效,如果没有超过0.9的输出,

    //则由重力确定其动作为漂流

      double BiggestSoFar = 0;

      action_type action = drift;

 

     for(int i=0; i<outputs.size(); ++i)

       {

         if((outputs[i] > BiggestSoFar)&& (outputs[i] > 0.9))

          {

            action =(action_type)i;

 

           BiggestSoFar= outputs[i];

          }

      }

   

     returnaction;

  }

 

由于只有当S形函数到达上升沿的边时,程序才能作出响应,故在params.ini中S形函数的激励响应被设置为0.2,这比一般的设置要低些。这样可以使响应曲线变得更陡峭一点,从而使网络对连接权重的变化感觉更灵敏,有助于加速演化。

 

你现在已经知道了神经网络是怎样构造出来的,下面就让我为你解释:演化机制是怎样进行工作的。

 

10.1.1.2外星人的演化(Alien Evolution)

象通常一样,控制器类CController是和一切类都有联系的类,但这一次其中不再包含我们熟悉的epoch函数。所有的孵化和突变操作这里均由Update方法进行处理。但在我向你说明这些之前,让我把CController类的定义整个给你看一遍。

 

class CController

(

  private:

   //游戏人用的枪

    CGun*            m_pGunTurret;

    

游戏者可以利用键盘上的left键和right键向左或向右移动用来发射子弹的枪(台)或战车。发射子弹利用Space bar键。如果你查看一下CGun类,你能发现另外有一种方法,叫AutoGun。它将使战车无规则地向左或向右移动,并随机地快速发射一系列的子弹。这是因为程序开始运行时外星人的行动显得相当迟钝,我们利用AutoGun的加速行动来快速孵化外星人,直到外星人群体规模达到要求的数目(缺省值200个)为止。

 

   //外星人孵化池

     multiset<CAlien> m_setAliens;

 

这是一个用来孵化所有外星人的基因组池。multiset是一个用于存放并使所有存放成员保持有序的STL容器。看一看下面的附加代码你就能进一步了解multisets是怎样使用的。

 

   //当前活动的外星人

   vector<CAlien>    m_vecActiveAliens;

 

这些是在游戏中活动着的外星人。当其中一个死去时,它就被由m_setAliens中适应性分数更高的成员之一突变产生的一个外星人所代替。

 

   int               m_iAliensCreatedSoFar;

 

这一变量在开始运行的一段时间内用来存放在此以前所有新创建的外星人数目。每一个新外星人首先需要在游戏环境中争取到适应性分数,然后才能被加入到multiset中。当这一变量数目达到要求的大小时,外星人就可以从multiset中进行孵化。

 

   int               m_iNumSpawnedFromTheMultiset;

 

   //存放背景星空中各星体顶点的缓冲区

vector<SPoint>   m_vecStarVB;

 

   //保存窗体的大小的数据

   int               m_cxClient,

   m_cyClient;

 

   //让程序运行尽可能地快的标志

    bool              m_bFastRender;

 

   //用来勾画游戏对象的用户画笔

    HPEN              m_GreenPen;

    HPEN              m_RedPen;

    HPEN              m_GunPen;

    HPEN              m_BarPen;

    void       WorldTransform(vector<SPoint>&pad);

 

     CAlien     TournamentSelection();

 

    public:

 

    CController(int cxClient, int cyClient);

    

    ~CController();

 

    //Update是程序的“主要劳动力”。由它来更新所有的游戏对象,并孵化新的外星

    //人加入到外星人群中

      boolUpdate();

    

      void Render(HDC &surface);

 

    //复位所有的控制变量,创造一个新的外星人群体,准备一次新的运行

     voidReset();

    

   //------------------------访问函数

    bool FastRender( ){return m_bFastRender;}

 };

 

 


STL注释(STL NOTE)

    一个multiset是一个标准模板库容器类(STL container class),它能根据成员所要求的排序类型,使成员保持有序。(缺省的排序类型是 < 操作)。它和它的小兄弟std::set很像,只是multiset允许包含复合数据类型,set则不允许这样。但要使用一个multiset, 你必须用#include语句包含set这个必要的头文件。

#include <set>

multiset<int> MySet

 

要把一成员加到一个multiset(或set) 中,可利用insert(插入)操作:

 

MySet.insert(3);

由于set和multisets都是利用二元树来实现的,无法把所有的成员按先后顺序列出,

 

 

所以,他们的元素不允许直接访问。代替它的,你只能利用iterator(迭代用指针变量)来访问其元素。

下面是一个应用STL的例子,它把10个随机整数插入到一个multiset中,然后在屏幕上有序地显示这些元素。

 

#include <iostream>

#include <set>

 

using namespace std;

 int main()

  {

const int SomeNumbers[10] = {12,3,56,10,3,34,8,234,1,17};

multiset<int> MySet;

    //首先,加入这几个数字

    for (int i=0; i<10; ++i)

     {
         MySet.insert(SomeNumbers[i]);
     }

 

//创建一个迭代指示变量
    multiset<int>::iterator CurrentElement = MySet.begin();

   

//并且用它来访问各成员

    while (CurrentElement != MySet.end())

     {

         cout << *CurrentElement << ", ";

 

         ++CurrentElement;

     }

     return 1;

  }

 

当运行这一程序时,其输出是: 

  l, 3, 3, 8, 10, 12, 17, 34, 56, 234

 

10.1.1.3 CController::Update方法(控制器类的更新方法)

这是程序的“主要劳动力”。在更新了战车(向外发子弹的地方)和天空中的星星之后,该方法对存放在m_vecActiveAliens中的所有外星人重复调用他们的更新函数CAlien::Update。更新函数查询每一个外星人的大脑,看他们在这一时间-步中采取什么行动,并相应地更新他们的位置。如果外星人已被子弹击中,或在行动时超出了窗体的边框之外,则它就应退出游戏,并加入到外星人的群体池内(它的适应性分数值就是它存活的滴答数)。由于池中是用一个std::multiset作为容器,故任何新加入的外星人都自动插入到已经(根据适应性)正确排序的队伍之中。如果所需要的外星人的人口数目已经达到要求,则程序就要删除multiset中最后的那个成员(最差的外星人)以保持池中总的外星人人口数目不变。

 

程序的下一步是用一个新的外星人来代替现已经死去(离开)的外星人。为了实现这一点,程序中使用了锦标赛选择法,从外星人池中分数最高的20% (缺省值)当中选出来。然后按突变率对这些个体的权重实行突变,再把结果加入到m_vecActiveAliens中。因此m_vecActiveAliens的大小永远保持常量。外星人个数的缺省值显示在屏幕上,可以在任何时候用参数CParams:iNumOnScreen进行设置。

 

注意:

    我在本程序中已经省略了杂交操作,因为我想证明没有杂交操作同样能够成功演化,并且,使用这一技术你可以要求你的孵化代码运行的尽可能的快。

在下面一章里,我将会用另一个理由来解释,为什么在演化神经网络时,删去杂交操作是一种好想法。

 

看一下下面代码清单,它将能帮助你清楚地理解这个过程。

 

 bool CController::Update()

  {

    //如果孵化出来的子孙足够,就把autogun方式关掉

   if(m_iNumSpawnedFromTheMultiset > CParams::iPreSpawns)

   {

     m_pGunTurret->AutoGunOff();

    

     m_bFastRender= false;

   }

 

    //从游戏者那里获取Gun turret(枪炮台,战车)位置的移动数值并进行更新;

    //如果子弹已从Gun turret发出,则子弹位置也要进行更新

      m_pGunTurret->Update();

 

    //移动天空中的星星

     for (int Str=0;Str<m_vecStarVB.size(); ++Str)

     {

      m_vecStarVB[str].y-= O.2;

 

      if(m_vecStarVB[Str].y < 0)

         {

         //创建一颗新的星

           m_vecStarVB[str].x = RandInt(0,CParams::WindowWidth) ;

           m_vecStarVB[str].y = CParams::WindowHeight;

         }

     }

 

     //更新外星人

     for(int i=O;i<m_vecActiveAliens.size(); ++i)

     {

      //如果外星人已“死”去,就用一个新的来代替

       if ( !m_vecActiveAliens[i].Update(m_pGunTurret->m_vecBullets,  m_pGunTurret->m_vPos))

        {

        //我们首先需要在繁殖群体中进行重新插入,所以死去外星人的适应性分数和代
          //的数目需要纪录下来

       m_setAliens.insert(m_vecActiveAliens[i]);

 

        //如果需要的外星人人口数已达到,删除multiset中性能的最差的那个外星人

       if (m_setAliens.size() >= CParams::iPopSize)

        {

          m_setAliens.erase(--m_setAliens.end());

        }

        ++m_iNumSpawnedFromTheMultiset;

    

        //如果是运行的早期,则我们仍然需要添加新的外星人

        if(m_iAliensCreatedSofar<= CParams::iPopSize)

        {

               m_vecActiveAliens[i] = CAlien();

 

               ++m_iAliensCreatedSoFar;

        }

 

       //否则,从multiset中选取一个,并对其应用突变

        else

        {

         m_vecActiveAliens[i] = TournamentSelection();

 

         m_vecActiveAliens[i].Reset();

 

         if (RandFloat() < 0.8)

             {

               m_vecActiveAliens[i].Mutate();

             }

         }

      }

    }//下一个外星人

 

    return true;

   }

 

这就是我们要讲的一切了!当你用此程序进行游戏时,你将会发现,外星人会想尽一切办法来保持自己尽可能长地活着,并学习怎样来对付你想杀死他的企图。

 

10.1.2运行BrainyAliens程序

     当你运行BrainyAliens(聪明的外星人)程序时,它一开始启动就以加快了的速度来运行。这样可以使外星人的群体在你开始杀死他们以前进行少量的演化和繁殖。这一预孵化群体包含的外星人人口数缺省为200,屏幕底部的蓝色进程条指示了程序的进行过程。在这一短时间内,程序进入自动射击(autogun)模式,发射台进行着一种随机的漫不经心的射击,但其过程你在屏幕上是无法看到的。当蓝条伸展到屏幕右边时,预孵化完成,程序就把发射台和发射子弹的控制权转交给了你,这时你就可以按照自己心中想的来操纵发射台和发射子弹了。

表10.1显示了缺省时的Brainy Aliens工程设置。

表10.1       Brainy Aliens工程的缺省设置

神经网络的参数

参     数

设 置 值

隐藏层数目

1

隐藏层神经元数目

15

激励响应

0.2

 

影响演化的参数

参   数

设 置 值

突变率

0.2

最大突变骚扰

1

外星人培育池大小

200

适应于孵化的百分比

20%

锦标赛竞争者数目

10

预孵化数目

200

 

其他参数

参   数

设 置 值

子弹速度

4

被显示外星人的最大数目

10

最大能用子弹数目

3

 

 

10.2练习题(Stuff to Try)

1. 象往常一样,用不同数目的隐藏层和不同数目的隐藏单元来试验,弄清它们对外星人的性能有什么影响。

2. 演化单独的神经网络,控制每一个外星人去向发射台丢炸弹。

3. 试一试不同类型地适应性函数。

4. 创建一个有外星人“波动”的游戏。你可以在波动之中把通常的GA技术结合到种群的演化中,从而可从二个世界之中得到最好的!


[第10章完,《神经网络进阶连载 也结束,接着还有神经网络深入的十个连载]


0 0