作者:Burak Kanber
翻译:王维强
原文:http://burakkanber.com/blog/machine-learning-in-other-languages-introduction/
遗传算法应该是我接触到的机器学习算法中的最后一个,但是我喜欢把它作为这个系列文章的开始,因为这个算法非常适合介绍“价值函数”或称“误差函数”,还有就是局部和全局最优概念,二者都是机器学习中的重要概念。
遗传算法的发明受自然界和进化论的启发,这对我来说非常酷。这并不奇怪,即使是人工神经网络(NN)也是从生物学发展起来的,进化是我们体会到的最好的通用学习算法,我们都知道人类的大脑是解决通用问题的最好利器。在人工智能和机器学习研究中两个成长最快的领域,也是我们生物存在的及其重要的两个部分,这就是我所感兴趣的遗传算法和神经网络,现在我把二者浓缩在一起。
我在前面使用的术语“通用”极其重要,对大多数的特别计算问题,你可能会找到比遗传算法更高效的方案,但是关键点不在于具体的实施,也不在于遗传算法。 使用遗传算法并不是在你遇到复杂的问题时,而是问题的复杂度已经成为问题,又或者你有一些完全不相干的参数需要面对。
一个典型的应用就是两足机器人行走问题。能让机器人靠两足行走是非常困难的,硬编码行走程序几乎不可能成功,即使你真能令机器人走起来,下一个机器人的平衡重心可能会轻微不同,也会使你的程序无法运行。你可以选择使用遗传算法来教会机器人如何学习行走,而不是直接教机器人行走。
我们这就来用Javascript搭建一个遗传算法。
问题
用Javascript写出一个算法繁殖出一段文本“Hello, World!”。
对程序员来说“Hello, World!”几乎是万物之始,我们使用遗传算法繁殖出这段文字也算是师出有名。注意这个问题有很高的人工参与性,当然我们可以直接在源码中打印出“Hello, World!”。不过这看起来很傻,既然已经知道了结果,还要这算法做什么呢?答案很简单,这只是个学习的训练,下一个遗传算法(使用PHP)将减少人工痕迹,但是我们总要先开始。
遗传算法基础
算法的基本目的就是生成一串“备选答案”并使用一系列的反馈知道这些备选离最优方案还有多远。离最优方案最远的的被淘汰掉,离最优方案近的留下来和其他备选方案结合并做轻微的突变,一次次修改备选方案并时刻检查离最优解的距离。
这些“备选答案”称作染色体。
染色体间结合,产生后代并且突变,优胜劣汰,适者生存,它们产生的后代或许具有更多适应自然选择的特性。
对于解决“Hello, World!”这样的问题,如此是不是很诡异?放心吧,遗传算法绝不是只善于解决这类问题。
染色体
染色体就是一个备选方案的表达,在我们的例子中,染色体本身就是一段字符,我们设定所有的染色体都是长度为13的字符串(Hello, World! 的长度就是13)。下面列出了一些符合备选方案的染色体:
- Gekmo+ xosmd!
- Gekln, worle”
- Fello, wosld!
- Gello, wprld!
- Hello, world!
很明显,最后一个是“正确”(或全局最优的)的染色体,但是我们如何测量染色体是否优秀呢?
价值函数
价值函数(或误差函数)是一个测量染色体优秀程度的方法,如果我们把他们称为“适应度函数”,那么所得分数越高越好,如果我们使用的是“价值函数”,当然分数越低越好。
在本例中,我们需要按以下规则定义价值函数:
针对字符串的每个字节,指出备选方案和目标方案之间在ASCII码上的差值,然后取差值的平方,以确保值为正数。
举例:如果我们有个字符“A” (ASCII 65) ,但是期望的字符应该是“C”(ASCII 67),那么价值计算的结果就为4(67 – 65 = 2, 2^2 = 4).
之所以采用平方,就是要确保值为正数,你当然也可以取绝对值。为了加深学习,请在实际操作中灵活应用。
采用这样的规则,我们能计算出以下5例染色体的价值:
- Gekmo+ xosmd! (7)
- Gekln, worle” (5)
- Fello, wosld! (5)
- Gello, wprld! (2)
- Hello, world! (0)
在本例中,该方法简单而且人工痕迹明显,很显然我们的目标是使代价(cost)为零,一旦为零,程序就可以停下来了。有时情况并不如此,比如当你在寻找最低代价时,需要用不同的方法结束计算,反之,如果寻找的是适应性最高分值时,可能需要用到其他的条件来停止计算。
价值函数是遗传算法中非常重要的内容,因为如果你足够聪明就能使用它来调和完全不相干的参数。 在本例中,我们只关注字符。但是如果你是在建立一套驾驶导航应用,需要权衡过路费,距离,速度,交通灯,糟糕的邻车还有桥梁等等情况,把这些完全不相干的参数封装进统一,优美,整洁的价值函数中来处理,最终依据参数不同的权重获得路径信息。
交配和死亡
交配是生活中的一个常态,我们会在遗传算法中大量使用它。交配绝对是一个魔幻时刻,两段染色体为分享彼此的信息坠入爱河。从技术层面描述交配就是“交叉”,但是我还是愿意称呼其为“交配”,因为能使所描绘的图景更加具有直觉性。
到目前为止我们还没有谈到遗传算法中的“种群”概念,但是我敢说只要你运行一个遗传算法,你某个时刻看到的可不仅仅是一个染色体这么简单。你可能会同时拥有20,100或5000条染色体,就像进化一样,你可能会倾向于让那些最强壮的染色体彼此交配,希望得到的后代比其父母更优秀。
实际上让字符交配是非常简单的,比如我们的例子“Hello,World!”,选取两段备选字符串(染色体),各自从中间截断成两个片段,这里你可以使用任何方法,如果你愿意甚至可以选取随机的点位进行截取。我们就选取中间位置吧,然后用第一段字符串的前半部分和第二段字符串的后半部分合成一个新的染色体(字符串)。继续用同样的方法把第二段字符串的前半部分和第一段字符串的后半部分合并成另一个新的染色体(字符串)。
以下面两个字符串为例:
- Hello, wprld! (1)
- Iello, world! (1)
从中间断开通过合并获得两个新的字符串,也就是两个新的孩子:
- Iello, wprld! (2)
- Hello, world! (0)
如上所见,两个后代中,有一个包含了父母的最佳特质,简直完美,另一个则非常糟糕。
交配就是把基因从父代传递到子代的过程。
突变
独自交配会产生一个问题:近亲繁殖。如果你只是让候选者们一代一代地交配下去,你会到达一个“局部最优”的境地并卡在那里出不来,这个答案虽然看起来还不错,但并不是你想要的“全局最优”。
把基因生活的世界想象成一个物理设定,这里具有起伏的山峰和沟谷,有那么一个山谷是这个世界中的最低处,同时也有很多其他小一些的谷地,恰恰基因被这些较小的谷地围绕,整体而言还在海平面之上。需要寻找一个解决方案,就像从山顶不同的随机位置滚落一些球,很显然这些球会卡在某个低处,他们中的很多会被山上的微小凹陷(局部最优)卡住。你的工作就是确保至少有一个球抵达整个世界的最低处:全局最优。既然球是从随机位置开始滚落的,就很难从开始处掌控过程,几乎不可能预测哪个球会被卡在哪里。但是你能做的是随机挑选一些球并给他们一脚,可能就是这一脚会帮助他们滚向更低处,想法就是稍微晃动一下系统使得这些球不要在局部最优处停留太久。
这就是突变,这是一个完全随机的由你选定一个神秘的未知基因产生一定比例个数的字符随机变化。
如下例所示,你停在了这两个染色体上面。
- Hfllp, worlb!
- Hfllp, worlb!
没错这是一个人为的案例,但真的会发生。你的两条染色体一模一样,意味着他们的子代与父代也一模一样,什么进展都没产生。但是如果100条染色体中有一个在某个字节上发生了突变,如上所示,第二条染色体仅仅发生一个突变,从 “Hfllp, worlb!” 变成了 “Ifllp, worlb!”。那么进化就会继续,因为子代和父代间再次产生了差异,是突变推动进化前行。
什么时候怎么突变完全取决于你自己。
再次,我们开始实验,后面我所提供的代码会有高达50%的突变几率,但是这也只是为了示范目的。你可以让它的突变几率低一些,比如1% 。我的代码中是让字符在ASCII码上移动1,你可以有自己更激进的设定。实验,测试,学习,这就是唯一的途径。
染色体:总结
染色体代表你要解决问题的备选方案,他们由表达本身组成(在我们的例子中,是一个长度为13的字符串),一个价值或适应性分数以及其函数,交配及突变的能力。
我喜欢把这些东西用OOP的观念考虑进去,染色体的类可以像下面这样定义:
属性:
- Genetic code
- Cost/fitness score
方法:
- Mate
- Mutate
- Calculate Fitness Score
我们现在考虑怎么让基因在遗传算法的最后一个谜团——种群中交互.
种群
种群就是一组染色体,种群通常会保持相同的尺寸,但是会随着时间的推移,发展到一个成本更均匀的状态。
你需要选择种群大小,我选择20。你可做任意选择,10,100或1000,如你所愿。当然有优势也有劣势,正如我几次提到的,实验并自己探索!
种群离不开“代”,一个典型的代可能会包含:
- 为每个染色体计算代价/适应性的分值
- 以代价/适应性分值排序染色体
- 淘汰一定数目的弱染色体
- 让一定数目的最强的染色体交配
- 随机突变某些成员
- 某种完整性测试, 如:你怎么知道该问题得到了解决?
开始和结束
创建一个种群非常简单,只是让随机产生的染色体充满整个种群即可。在我们的例子中,完全随机字符串的成本分数将会很恐怖,所以在我的代码中以平均分30000的价值分数开始。数目庞大不是问题,这就是进化的目的,也是我们在这里的原因。
知道如何停止种群繁衍需要一点小技巧,当前的例子很简单,当价值分数为0时就停止。但这不总是那么管用,有时你甚至不知道最小值是什么,如果用适应性代替的话,你不知道可能的最大值是什么。
在这些情况下,你应该指定一个完整的标准,可以是任何你想要的,但是这里建议用下面的逻辑跳离算法
如果经过一千代的繁衍,最佳值也没什么变化,可以说该值就是答案了,该停止计算了。
这个判断标准可能意味着你永远得不到全局最优解,但是很多情况下你根本不需要得到全局最优解,足够接近就行了。
代码
我还是喜欢OOP方法,当然也喜欢粗旷简单的代码。 我会尽可能采用简单直接的策略,即使在某些地方还比较粗糙。
(注意:即使我在上文中把基因改成了染色体,这里代码中还是使用基因作为术语,只是语义上有些区别罢了。)
var Gene = function(code) { if (code) this.code = code; this.cost = 9999; }; Gene.prototype.code = ''; Gene.prototype.random = function(length) { while (length--) { this.code += String.fromCharCode(Math.floor(Math.random()*255)); } };
很简单,该类的构造函数接受一个字符串作为参数,设定一个“价值”(cost),一个辅助函数用来生成新的随机的染色体。
Gene.prototype.calcCost = function(compareTo) { var total = 0; for(i = 0; i < this.code.length; i++) { total += (this.code.charCodeAt(i) - compareTo.charCodeAt(i)) * (this.code.charCodeAt(i) - compareTo.charCodeAt(i)); } this.cost = total; };
价值函数把“模型”——字符串作为一个参数,和自身的字符串在ASCII编码方面做差运算,然后取其平方值。
Gene.prototype.mate = function(gene) { var pivot = Math.round(this.code.length / 2) - 1; var child1 = this.code.substr(0, pivot) + gene.code.substr(pivot); var child2 = gene.code.substr(0, pivot) + this.code.substr(pivot); return [new Gene(child1), new Gene(child2)]; };
交配函数以一个染色体为参数,找到中间点,以数组的方式返回两个新的片段。
Gene.prototype.mutate = function(chance) { if (Math.random() > chance) return; var index = Math.floor(Math.random()*this.code.length); var upOrDown = Math.random()
突变函数把一个浮点值作为参数,代表染色体的突变几率。
var Population = function(goal, size) { this.members = []; this.goal = goal;
this.generationNumber = 0; while (size--) { var gene = new Gene(); gene.random(this.goal.length); this.members.push(gene); } };
种群类中的构造器以目标字符串和种群大小作为参数,然后用随机生成的染色体建立种群。
Population.prototype.sort = function() { this.members.sort(function(a, b) { return a.cost - b.cost; }); }
定义一个 Population.prototype.sort 方法作为一个辅助函数对种群依据他们的价值(cost)分数排序。
Population.prototype.generation = function() { for (var i = 0; i < this.members.length; i++) { this.members[i].calcCost(this.goal); } this.sort(); this.display(); var children = this.members[0].mate(this.members[1]); this.members.splice(this.members.length - 2, 2, children[0], children[1]); for (var i = 0; i < this.members.length; i++) { this.members[i].mutate(0.5); this.members[i].calcCost(this.goal); if (this.members[i].code == this.goal) { this.sort(); this.display(); return true; } } this.generationNumber++; var scope = this; setTimeout(function() { scope.generation(); } , 20); };
种群的生产方法是最重的部分,其实也没有什么魔法。display()方法只是把结果渲染到页面上,我设置了代际间隔时长,不至于让事情爆炸般增长。
注意,在本例中我仅仅让排在最顶端的两个染色体交配,至于在你自己的实践中怎么处理,可多做各种不同的尝试。
window.onload = function() { var population = new Population("Hello, world!", 20); population.generation(); };
还是看实例吧:
http://jsfiddle.net/bkanber/BBxc6/?utm_source=website&utm_medium=embed&utm_campaign=BBxc6