|
4.5 神经网络的编码(Encoding the Network ) 在本书的开始几章中,你已经看到过怎样用各种各样的方法为遗传算法编码。但当时我并没有向你介绍过一个用实数编码的具体例子,因为我知道我要留在这里向你介绍。我曾经讲到,为了设计一个前馈型神经网络,编码是很容易的。我们从左到右读每一层神经细胞的权重,读完第一个隐藏层,再向上读它的下一层,把所读到的数据依次保存到一个向量中,这样就实现了网络的编码。因此,如果我们有图14所示的网络,则它的权重编码向量将为:
8 `# q2 P( X, @ 0.3, -O.8, -O.2, 0.6, O.1, -0.l, 0.4, 0.5
: z: K% G! I( W4 q5 Z 在这一网络中,为了简单,我没有把偏移值的权重包括进去。但在实际实现编码时,你必须包含偏移值这个权重,否则你肯定无法获得你所需要的结果。 $ ]3 v( O( q0 {' ]( B& l3 T
& [+ F0 d, ^- x; `" i: J
; g6 c$ @6 n2 ~# o2 ~/ w' F
( U0 O9 L( P f' K9 n0 k# o图14 为权重编码。
5 C, c6 L9 |; K& W 在此之前讲的事情你都懂了吗?好极了,那下面就让我们转来考虑,怎样用遗传算法来操纵已编码的基因吧。
3 J" R! g# I8 G2 A
. C0 H. k) X/ O. F" O# L4.6 遗传算法(The Genetic Algorithm ) 到此,所有的权重已经象二进制编码的基因组那样,形成了一个串,我们就可以象本书早先讨论过的那样来应用遗传算法了。遗传算法(GA)是在扫雷机已被允许按照用户指定的帧数(为了某种缘故, 我下面更喜欢将帧数称作滴答数,英文是ticks)运转后执行的。你可以在ini文件中找到这个滴答数(iNumTicks)的设置。下面是基因组结构体的代码。这些对于你应该是十分面熟的东西了。
1 s7 D1 C$ Y. I! s/ S) cStruct SGenome { vector <double> vecWeights; ' I3 \! K9 a1 Y) v1 b
double dFitness;
2 U! h- r4 R: g SGenome():dFitness(0) {} ! s$ n( N# z$ n; v# e
SGenome(vector <double> w, double f):vecWeights(w),dFitness(f){} 6 x" _* ]4 k5 `& Q9 T, d
//重载'<'的排序方法 friend bool operator<(const SGenome& lhs, const SGenome& rhs) { return (lhs.dFitness < rhs.dFitness); } };
, A5 B: k# _6 N! k. X/ X 从上面的代码你可看出,这一SGenome结构和我们在本书所有其他地方见到的SGenome结构几乎完全一致,唯一的差别就是这里的染色体是一个双精度向量std::vector。因此,可以和通常一样来应用杂交操作和选择操作。但突变操作则稍微有些不同,这里的权重值是用一个最大值为dMaxPerturbation的随机数来搔扰的。这一参数dMaxPerturbation在ini文件中已作了声明。另外,作为浮点数遗传算法,突变率也被设定得更高些。在本工程中,它被设成为0.1。 下面就是扫雷机工程遗传算法类中所见到的突变函数的形式:
: c$ r0 o0 t% p d8 t' u: Rvoid CGenAlg::Mutate(vector<double> &chromo) { // 遍历权重向量,按突变率将每一个权重进行突变 for (int i=0; i<chromo.size(); ++i) { // 我们要骚扰这个权重吗? if (RandFloat() < m_dMutationRate) { // 为权重增加或减小一个小的数量 chromo += (RandomClamped() * CParams::dMaxPerturbatlon); } } } 1 J7 c* l( Q, \2 z- l$ n- s3 g2 I# l
如同以前的工程那样,我已为v1.0版本的Smart Minesweepers工程保留了一个非常简单的遗传算法。这样就能给你留下许多余地,可让你利用以前学到的技术来改进它。就象大多数别的工程一样,v1.O版只用轮盘赌方式选精英,并采用单点式杂交。
; A! P# A. v* u! w注意: 当程序运行时,权重可以被演化成为任意的大小,它们不受任何形式的限制。 + n( H( r; ~: o+ w
4 b# _7 G/ l/ u s3 k
4.7 扫雷机类(The CMinesweeper Class ) & m# J* O; q3 ]8 K% ~
这一个类用来定义一个扫雷机。就象上一章描述的登月艇类一样,扫雷机类中有一个包含了扫雷机位置、速度、以及如何转换方向等数据的纪录。类中还包含扫雷机的视线向量(look-at vector);它的2个分量被用来作为神经网络的2个输入。这是一个规范化的向量,它是在每一帧中根据扫雷机本身的转动角度计算出来的,它指示了扫雷机当前是朝着哪一个方向,如图11所示。 下面就是CMinesweeper扫雷机类的声明: 4 g6 s! ]9 e/ V3 @
class CMinesweeper { private: // 扫雷机的神经网络 CNeuralNet m_ItsBrain; . c, C3 l; |* ^ L$ I
// 它在世界坐标里的位置 SVector2D m_vPosition;
; f& ]" v: S* x- M6 j6 J // 扫雷机面对的方向 SVector2D m_vLookAt; 6 v3 t1 | I4 R' N, W. h
// 它的旋转(surprise surprise) double m_dRotation;
7 E8 m, s) N9 F( k- l double m_dSpeed;
) J" s" C5 y$ M T3 u // 根据ANN保存输出 double m_lTrack, m_rTrack; % X8 B ~, c7 n" m
9 ~8 A0 }6 {, A. U: p
- n- p3 t$ I! c, w% h/ p$ A0 ]8 a' s; Z" \
m_lTrack和m_rTrack根据网络保存当前帧的输出。 这些就是用来决定扫雷机的移动速率和转动角度的数值。 # b/ e4 Y0 t% b9 C1 [
9 K9 k* Y; L! N
% p8 p. s7 B6 D$ `2 l4 h+ h9 G \
1 b( D. h9 v' Y+ e, s // 用于度量扫雷机适应性的分数 double m_dFitness;
3 k. v, Z9 P" i/ d" E( {- c& s7 P$ [
7 j6 W. g: {3 t6 A8 n* E
2 ?9 X! y. h* F3 Z 每当扫雷机找到一个地雷,它的适应性分数就要增加。 . A4 Z2 G, _. g# Z6 U
3 i3 l) U8 I; e% U' f0 {$ p/ O7 U& A2 c3 @. h# W8 v0 ?
+ {( u% C: |$ c% T' S$ F
// 扫雷机画出来时的大小比例 double m_dScale;
M! ?4 |' z. a4 s // 扫雷机最邻近地雷的下标位置 int m_iClosestMine;
/ h8 P' k# p% E, C% @* i6 B$ y# R6 u* x' k l- N* e
: D) E& w" V# p& u5 O" s$ ~% R- x% ?7 Y
在控制器类CControl1er中,有一个属于所有地雷的成员向量std::vector。而m_iClosestMine就是代表最靠近扫雷机的那个地雷在该向量中的位置的下标。
5 B& r# v/ k- e8 B' ]. [2 m/ C. C: g! d
* R1 T f2 B( j6 Z+ ^: V! r/ x9 o+ e# z9 \
public: ! F5 _" `. L; {" q
CMinesweeper(); 7 G: U8 [2 s- U. Y. N
// 利用从扫雷机环境得到的信息来更新人工神经网 bool Update(vector<SVector2D> &mines);
- F3 m2 |( M; g. X7 K // 用来对扫雷机各个顶点进行变换,以便接着可以画它出来 void WorldTransform(vector<SPoint> &sweeper);
0 e5 m# ~: _: ?3 S // 返回一个向量到最邻近的地雷 5Vector2D GetClosestMine(vector<SVector2D> &objects);
: `. U* W3 z) r2 s // 检查扫雷机看它是否已经发现地雷 int CheckForMine(vector<SVector2D> &mines, double size); 1 u2 K. n9 Z# A- a3 E
void Reset(); 4 P7 j P" D5 \: R; w& H7 d2 g% M
// ----------------- 定义各种供访问用的函数 SVector2D Position()const { return m_vPosition; } void IncrementFitness(double val) { m_dFitness += val; } double Fitness()const { return m_dFitness; } void PutWeights(vector<double> &w) { m_ItsBrain.PutWeights(w); } int GetNumberOfWeights()const { return m_ItsBrain.GetNumberOfWeights(); } }; " r" d0 i% z9 l% z& a; \$ k
8 [4 Y8 B# J% W# i z6 k( ~4.7.1 The CMinesweeper::Update Function (扫雷机更新函数) 8 b" U5 Y" q1 ^- C3 q8 ~3 ~8 s
需要更详细地向你说明的CMinesweeper类的方法只有一个,这就是Update更新函数。该函数在每一帧中都要被调用,以更新扫雷机神经网络。让我们考察这函数的肚子里有些什么货色: / f# O, |- y2 G- m _& f) F% l
bool CMinesweeper::Update(vector<SVector2D> &mines)
& X8 N( x* e" [ L: m{ //这一向量用来存放神经网络所有的输入 vector<double> inputs; $ H$ i( Q" ~6 Q1 N- T4 }
//计算从扫雷机到与其最接近的地雷(2个点)之间的向量 SVector2D vClosestMine = GetClosestMine(mines); / g% q& A6 d0 y3 C$ \2 }3 v
//将该向量规范化 Vec2DNormalize(vClosestMine); 1 \3 l5 c/ F2 J* y! T& U$ i8 E
& k: ?) n3 G- k0 @ j; V0 V2 ^
首先,该函数计算了扫雷机到与其最靠近的地雷之间的向量,然后使它规范化。(记住,向量规范化后它的长度等于1。)但扫雷机的视线向量(look-at vector)这时不需要再作规范化,因为它的长度已经等于1了。由于两个向量都有效地化成了同样的大小范围,我们就可以认为输入已经是标准化了,这我前面已讲过了。
. D: q A* X* f2 O6 v; F4 `* c //加入扫雷机->最近地雷之间的向量 Inputs.push_back(vClosestMine.x); Inputs.push_back(vCIosestMine.y);
" m7 b! [. v& q2 o7 Y6 g4 E //加入扫雷机的视线向量 Inputs.push_back(m_vLookAt.x); Inputs.push_back(m_vLookAt.y);
4 {& {$ a9 I: `! ^# Z3 `) I: y4 v* ] //更新大脑,并从网络得到输出 vector<double> output = m_ItsBrain.Update(inputs);
/ o- z! `( |! V" W
. x9 |/ W% S( j; P" O4 V# W 然后把视线向量,以及扫雷机与它最接近的地雷之间的向量,都输入到神经网络。函数CNeuralNet::Update利用这些信息来更新扫雷机网络,并返回一个std::vector向量作为输出。
5 }9 H7 F, J" e //保证在输出的计算中没有发生错误 if (output.size() < CParams::iNumOutputs) { return false; }
6 i7 ]# O6 T4 _ ?- i; P$ C // 把输出赋值到扫雷机的左、右轮轨 m_lTrack = output[0]; m_rTrack = output[1];
# D# l, X+ f& Q& s- a. w
# @7 C- {+ u- O& \! a; o; m% j: w 在更新神经网络时,当检测到确实没有错误时,程序把输出赋给m_lTrack和m_rTrack。 这些值代表施加到扫雷机左、右履带轮轨上的力。 / C' [- ~7 |/ @
// 计算驾驶的力 double RotForce = m_lTrack - m_rTrack;
) q6 q. @. t) ^7 y2 @, C // 进行左转或右转 Clamp(RotForce, -CParams::dMaxTurnRate, CParams::dMaxTurnRate); 9 }3 J" ~. A& h+ K/ m: P
m_dSpeed = (m_lTrack + m_rTrack); 5 V$ a. p$ l2 S; g) k4 f
扫雷机车的转动力是利用施加到它左、右轮轨上的力之差来计算的。并规定,施加到左轨道上的力减去施加到右轨道上的力,就得到扫雷机车辆的转动力。然后就把此力施加给扫雷机车,使它实行不超过ini文件所规定的最大转动率的转动。而扫雷机车的行进速度不过就是它的左侧轮轨速度与它的右侧轮轨速度的和。既然我们知道了扫雷机的转动力和速度,它的位置和偏转角度也就都能更新了。 2 Y8 x# H+ {# c. ?# }$ b4 o
//更新扫雷机左右转向的角度 m_dRotation += RotForce;
9 U$ O/ M7 h1 i5 Z1 I q! [ // 更新视线角度 m_vLookAt.x = -sin(m_dRotation); m_vLookAt.y = cos(m_dRotation);
: O+ H3 ]) b9 k' S8 I% A // 更新它的位置 m_vPosition += (m_vLookAt* m_dSpeed);
: D! g) V: f1 I // 如果扫雷机到达窗体四周,则让它实行环绕,使它不至于离开窗体而消失 If (m_vPosition.x > CParams::WindowWidth) m_vPosition.x = 0; If (m_vPosition.x < 0) m_vPosition.x = CParams::WindowWidth; If (m_vPosition.y > CParams::WindowHeight) m_vPosition.y = 0; If (m_vPosition.y < D) m_vPosition.y = CParams::WindowHeight;
I$ U6 G# z' S4 s& X4 y 为了使事情尽可能简单,我已让扫雷机在碰到窗体边框时就环绕折回(wrap)。采用这种方法程序就不再需 要做任何碰撞-响应方面的工作。环绕一块空地打转对我们人来说是一桩非常不可思议的动作,但对扫雷机,这 就像池塘中的鸭子。 8 r& ~/ }; b6 l, A7 t
Returen true; } ! m" u: c. `! i' H
/ P, F! J7 r; L$ Z3 A4.8 CController Class (控制器类) CController类是和一切都有联系的类。图15指出了其他的各个类和CController类的关系。 下面就是这个类的定义: " r8 h! ^9 ?* }; p! V* e: T# D; B
class CController { private: // 基因组群体的动态存储器(一个向量) vector<SGenome> m_vecThePopulation;
! s4 c# t7 R% a0 x& W
& s% d, t3 M3 x( O3 U4 \, O. @
; w- t5 @* \ `) s( Z1 b0 ^9 `3 ^2 l" |5 d1 R8 c7 B2 b4 _4 w. h
图15 minesweeper工程的程序流程图 6 R) e( n* `- Q( R3 d+ @
+ W: J8 p7 N2 @7 \
// 保存扫雷机的向量 vector<CMinesweeper> m_vecSweepers;
3 f* l5 }: n8 x5 k // 保存地雷的向量 vector<SVector2D> m_vecMines;
# F: k$ w( `: d' e8 B4 W" S1 s, Y6 n. {" A( m. G
// 指向遗传算法对象的指针 CGenAIg* m_pGA;
3 K' y$ n2 J1 x4 W! [+ j- _ int m_NumSweepers; 7 _) c1 V" ]" @- J, Y' B2 B
int m_NumMines; * B4 ~ b B) S/ \% |$ V
// 神经网络中使用的权重值的总数 int m_NumWeightsInNN; ) P0 V0 i( b3 I& o7 {; t7 o
// 存放扫雷机形状各顶点的缓冲区 vector<SPoint> m_SweeperVB;
- s9 e" e* K1 {% a/ I2 ` // 存放地雷形状各顶点的缓冲区 vector<SPoint> m_MineVB;
* }% z, v) W: i2 t6 W s // 存放每一代的平均适应性分数,供绘图用 vector<double> m_vecAvFitness; . o% I* T8 P+ Y- N% r5 A- b
// 存放每一代的最高适应性分 vector<double> m_vecBestFitness;
8 z2 @+ W$ `; l( I0 ~; o6 a2 X // 我们使用的各种不同类型的画笔 HPEN m_RedPen; HPEN m_BluePen; HPEN m_GreenPen; HPEN m_OldPen; ! B$ u3 r" _$ P- b8 N7 ~# A
// 应用程序窗口的句柄 HWND m_hwndMain;
! h0 C8 i( x# N8 O$ ^+ r // 切换扫雷机程序运行的速度 bool m_bFastRender;
4 p/ i! H( e" W: E. J // 每一代的帧数(滴答数) int m_iTicks; 2 B! W' Y) D+ u& T
// 代的计数 int m_iGenerations; ) O5 w$ e: C6 c o+ _- ]- W3 y
// 窗体客户区的大小 int cxClient,cyClient; 9 ?% Z1 v6 c; j3 K) O" h9 O" p0 f
// 本函数在运行过程中画出具有平均-,和最优适应性值的图 void PlotStats(HDC surface);
+ [5 G2 Y1 V3 @5 P' O* ?' epublic:
& [# N6 j1 d* e8 m+ ~5 I6 p* c0 [ CController(HWND hwndMain); % u/ t k8 Z' N5 I: R$ }4 e
~CController(); ! B2 W( S; p8 c" M' [
void Render(HDC surface);
; V' h0 o1 f8 R4 K void WorldTransform(vector<SPoint> &VBuffer, SVector2D vPos);
+ f) ?; J* ~+ i bool Update(); ' p( t5 h; H- j
// 几个公用的访问方法 bool FastRender() { return m_bFastRender; } void FastRender(bool arg){ m_bFastRender = arg; } void FastRenderToggle() { m_bFastRender = !m_bFastRender; } }; * `' V/ ~7 t+ ?
) b# P) u* { U7 _: v
当创建CController类的某个实例时,会有一系列的事情发生:
8 S: `( O6 ^ W) N" g; x*创建CMinesweeper对象。 *统计神经网络中所使用的权重的总数,然后此数字即被利用来初始化遗传算法类的一个实例。 *从遗传算法对象中随机提取染色体(权重)并(利用细心的脑外科手术)插入到扫雷机的经网络中。 *创建了大量的地雷并被随机地散播到各地。 *为绘图函数创建了所有需要用到的GDI画笔。 *为扫雷机和地雷的形状创建了顶点缓冲区。
" n) L4 H0 k( e6 a 所有的一切现都已完成初始化,由此Update方法就能在每一帧中被调用来对扫雷机进行演化。
+ m( ]/ h, T. e' j8 @5 x, M- d: W. ?. [
( Y% z5 f/ w. Q9 a, B) L# K4.8.1 CController::Update Method (控制器的更新方法)
% L" }/ P* S% V2 R* G 控制器更新方法CController::Update方法(或函数)在每一帧中都要被调用。当调用update函数时,函数的前一半通过对所有扫雷机进行循环,如发现某一扫雷机找到了地雷,就update该扫雷机的适应性分数。由于m_vecThePopulation包含了所有基因组的拷贝,相关的适应性分数也要在这时进行调整。如果为完成一个代(generation)所需要的帧数均已通过,本方法就执行一个遗传算法时代(epoch)来产生新一代的权重。这些权重被用来代替扫雷机神经网络中原有的旧的权重,使扫雷机的每一个参数被重新设置,从而为进入新一generation做好准备。 ) E$ D5 U1 Y2 t
bool CController::Update() { // 扫雷机运行总数为CParams::iNumTicks次的循环。在此循环周期中,扫雷机的神经网络 // 不断利用它周围特有的环境信息进行更新。而从神经网络得到的输出,使扫雷机实现所需的 // 动作。如果扫雷机遇见了一个地雷,则它的适应性将相应地被更新,且同样地更新了它对应 // 基因组的适应性。 if (m_iTicks++ < CParams::iNumTicks) { for (int i=O; i<m_NumSweepers; ++i) { //更新神经网络和位置 if (!m_vecSweepers.Update(m_vecMines)) { //处理神经网络时出现了错误,显示错误后退出 MessageBox(m_hwndMain, 'Wrong amount of NN inputs!", "Error", MB_OK);
4 z* Q3 n( Y4 V h' M- P0 i, N return false; } , J2 S" T9 a9 b4 V7 x
// 检查这一扫雷机是否已经发现地雷 int GrabHit = m_vecSweepers.CheckForMine(m_vecMines, CParams::dMineScale);
& ^8 _! [' M; c8 J if (GrabHit >= 0) { // 扫雷机已找到了地雷,所以要增加它的适应性分数 m_vecSweepers.IncrementFitness();
5 ~6 o5 X0 _: }9 m. b4 O# o- Z // 去掉被扫雷机找到的地雷,用在随机位置放置的一个新地雷来代替 m_vecMines[GrabHit] = SVector2D(RandFloat() * cxClient, RandFloat() * cyClient); } $ \+ N ?6 P! t9 Q$ c
// 更新基因组的适应性值 m-vecThePopulation.dFitness = m_vecSweepers.Fitness(); } } // 一个代已被完成了。 // 进入运行遗传算法并用新的神经网络更新扫雷机的时期 else { // 更新用在我们状态窗口中状态 m_vecAvFitness.push_back(m_pGA->AverageFitness()); m_vecBestFitness.push_back(m_pGA->BestFitness()); . I. Y: i% O* q
// 增加代计数器的值 ++m_iGenerations; ' o* m# ^* I! k {! P
// 将帧计数器复位 m_iTicks = 0;
% C$ t! a+ y) A/ v7 j- f // 运行GA创建一个新的群体 m-vecThePopulation = m_pGA->Epoch(m_vecThePopulation);
' P5 H4 I# q7 y$ _- @# R$ { // 在各扫雷机中从新插入新的(有希望)被改进的大脑 // 并将它们的位置进行复位,等 for(int i=O; i<m_NumSweepers; ++i) {m_vecSweepers.m_ItsBrain.PutWeights(m_vecThePopulation.vecWeights);
$ \, ~6 g$ y& ]" G; W4 d) d m_vecSweepers.Reset(); } } returen true; } . u& a8 w" H% l4 w
1 U1 M. ~8 U9 k0 _1 M7 N' D L概括起来,程序为每一世代做的工作是: / V/ C' [4 A0 g; S
l.为所有扫雷机和为iNumTicks个帧组织循环,调用Update函数并根据情况增加扫雷机适应值的得分。 2.从扫雷机神经网络提取权重向量。 3.用遗传算法去演化出一个新的网络权重群体。 4.把新的权重插入到扫雷机神经网络。 5.转到第1步进行重复,直到获得理想性能时为止。 ( }, g1 J" Q: a$ J1 f4 \/ a
最后,表3列出了Smart Sweepers工程 v1.0版所有缺省参数的设置值。
0 w' `, Z, |/ G' i# b3 O& I; |, f, H# y
4.9 运行此程序 (Running the Program ) ! h9 d; g- H8 ^( l; r4 h1 K1 m
当你运行程序时,“F”键用来切换2种不同的显示状态,一种是显示扫雷机怎样学习寻找地雷,一种是示在运行期中产生的最优的与平均的适当性分数的统计图表。 当显示图表时,程序将会加速运行。 ( I, T9 V8 Y& T7 \/ B6 N- e5 Y! G7 q" l
5 f& {8 Q* {$ ~: o |