haoming

机器学习:遗传算法介绍

haoming · 2017-01-12翻译 · 335阅读 原文链接 十年踪迹审校

我喜欢通过遗传算法(有时候缩写为"GA")来开始我的机器学习课程。 遗传算法可能是我接触过的最不实用的算法,但是我喜欢把它作为开端,因为这个算法很迷人,而且通过这个算法可以学习"成本函数"或者说"误差函数"还有全局最优和局部最优的概念,这些概念在所有机器学习算法中都是共同的而且非常重要。

Introduction to "Machine Learning in Javascript" 给本文和本系列的其它文章提供了一个很好的介绍和背景。

遗传算法是受自然界和进化论启发所提出的,这听起来对我来说非常酷。这并不奇怪,人工神经网络("NN")也是从生物学上建模而来的,进化是我们所见过的最好的通用学习算法,而人脑是我们所知最好的通用解题器。遗传和神经网络是生物存活的两个非常重要的部分,也是人工智能和机器学习研究的两个快速发展的领域。虽然我非常想谈谈我研究的GA"学习算法"和NN"解题器"术语的区别,但是现在我们还是先把NN的内容放在一边,全神贯注于GA。

上文我使用了一个非常重要的术语"通用"。对于任何一个特定的计算问题,你都可能找到一个算法比GA更高效地解决它。但解决特定地计算问题并不是这个练习的重点,也不是GA的重点。你要使用GA不是因为你有一个复杂的问题,而是你有一个复杂的关于多个问题的问题。或者说,当你有一个复杂的但又不相干的参数集合时,你可以使用GA

我想到的一个应用是双足机器人行走功能。使机器人用两个腿来行走是非常困难的。硬编码一个行走程序几乎肯定会失败。 即使你成功得使一个机器人行走,下一个不同的机器人可能会有一个稍微不同的平衡中心,你解决上一个机器人的算法将不再有效。为了不忍受无可避免的心碎,你应该用GA去"教机器人学习行走"而不是简单地"教机器人行走" 。

让我们用JavaScript来实现GA吧。

问题

用JavaScript构建一个遗传算法程序产生文本"Hello, World!"。

自然的,每个东西都是从"Hello, World!"开始,所以构建一个生产这个短语的GA程序也是恰当的。注意,这是一个非常做作的问题。我们甚至一度会打短语"Hello, World!"在源代码中! 如果你知道想要的结果,为什么要第一时间编写一个算法呢,这似乎很傻。答案很简单,这是一个为了学习的练习。下一个 GA练习(它将基于PHP)将不会那么做作,但我们会从另外一个地方开始。

遗传算法基础

GA的基础方法是产生一群"候选解"然后用某种反馈去计算候选解到最优解的接近程度。 让远离最优的候选解死去,然后再也不会被见到。接近最优的候选解相互结合,并且可能会轻微地变异; 这是试图不时地调整候选解然后观察候选解是否离最优解更近或者更远。

这些"候选解"被称为基因染色体。(注意:这里我使用的术语基因是错误的。基因在技术上是候选解决方案的单个字符,而整体称为染色体。语义很重要!)

染色体配对产生后代然后变异。它们要么由于适者生存的法则而死亡,要么被允许产生有更理想特征适应自然选择的后代。

这可能是一个奇怪的方法来思考解决"Hello, World!"问题,但是,坚持下去。这个例子不是GA可以解决的唯一问题。

染色体

染色体是解决方案的模型,在我们的案例中,染色体本身是一个字符串。让我们假设所有的染色体长度是13个字符(和"Hello, World!"一样长)。这里是一些可能的染色体,它们可以是问题的候选解决方案:

  • 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)

在这个案例中,因为问题是简单做作的,我们知道我们在寻找一个成本为0的结果,然后结束算法。有时候情况不是这样的。有时候你仅仅是在寻找你可以找到的最小成本,你需要想不同的方法来结束运算。其它一些时候,你在找你可以找到的最高的"适应值",同样的你需要去想其它一些准则用来终止运算。

成本函数是GA非常重要的一个方面,因为如果你足够聪明,你可以用它去调和完全无关的形参.在我们的案例中,我们只看字母。但是如果你正在构建一个驾驶导航app然后需要去衡量过路费vs交通灯vs坏社区vs桥梁你要怎么做?这些是完全不相干的形参,但你可以通过为每个形参应用不同的权重把它们简化为一个整齐的成本函数。

配对和死亡

配对是生命的本质,在GA中我们大量使用它。配对是一个神奇的时刻,相爱的两个染色体彼此共享。配对的技术术语是"交叉",但在这里我会继续叫它"配对",因为配对描绘了一个更直观的图像。

我们还没有谈到GA中的"人口"(我们会在稍晚讨论它)但是现在我只是说当你运行一个GA你一次不会只看到一个染色体。一次你的全部人口可能是20个100个或者5000个。就像进化一样,怀抱着后代比任何一个父母更健康的期望,你可能倾向于让人口中最好最强壮的染色体互相交配。

配对字符串,就像在我们的"Hello, World!"的例子里,是非常简单的。你可以选择两个候选人(两个字符串,两个染色体)然后在字符串中间选择一个点。如果你想,这个点可以是正中心或者随机的。实验一下吧!选中中心点(称作主元点),然后通过组合第一个的前半边和另一个的后半边再倒过来生成两个新的染色体。

用这两个字符串举个例子:

  • Hello, wprld! (1)

  • Iello, world! (1)

从中间切开它们,然后交互一半生成了以下这两个新的"小孩":

  • Iello, wprld! (2)

  • Hello, world! (0)

正如你所看到的,后代包括一个私生子它带有两个父母的最坏特征,但也有一个天使的孩子它带有两个父母最好的特征。

配对是把基因传给下一代的方式。

突变

仅仅是配对有一个问题:育种。如果你做的全部是配对你的候选人一代一代,你会陷入一个"局部最优"困境:一个个答案是好的但不见得是"全局最优"(你期待的最好的)。

(我最近被告知上面的段落有点误导人。一些读者得到染色体进化最重要的方面是配对的印象,在实践中一个GA如果不结合配对和突变将取得很少的结果。突变帮助我们从先前的优秀的解决方案发现更多最佳的解决方案[很多问题的解决方案,像我们的Hello World例子,可以被划分最优的子解决方案,像"Hello"和"World"],但是只有突变可以推进在新方向搜索解决方案。)

想象一个基因生活的世界作为物理设定。它布满丘陵和各种各样奇怪的山峰和山谷。这里面有一个最矮的山谷,但是这里还有很多其它小山谷--虽然这些山谷比直接环绕它的陆地要矮,它们仍然在海平面以上。搜索一个解决方案就像随机放置一堆球在丘陵上。你让这些球动,然后它们就会滚下山。最后,这些球会陷入山谷中--但是大部分会陷入一个随机的最矮的山谷这些山谷仍然比丘陵要高得多(局部最优)。你的工作就是确保最少其中一个球落入整个地图的最低点:全局最优。因为这些球是在随机的地方开始,在开始的时候是很难做到全局最优,而且预测哪个球会困在哪里也是不可能的。但是,你可以做的就是,随机地观察一些球然后踢它们一下。踢一下可能有帮助可能帮倒忙--但是这里地想法是去摇动一下系统来确保系统不会被困在局部最优太久。

这被称作突变。这是一个完全随机地过程通过它你瞄准一个毫无戒心地染色体然后用足够地辐射爆破它来使其中一个字母随机改变。

这里我们用一个例子举例说明。我们说我们有这两个染色体:

  • Hfllp, worlb!

  • Hfllp, worlb!

再一次,一个做作地例子,但它发生了。你的两个染色体是一样的。这意味着它们地孩子将会和它们地父母一样,不会有进步。但是如果100个染色体中有一个有一个字符随机突变,上面的染色体#2变成"Ifllp, world!"就只是时间的问题。然后进化会继续,因为小孩最终将会再一次和父母不一样。突变推动进化前进。

如何和几时突变是由你来决定的。再一次,做实验研究吧。后面我将给你的代码有一个非常高的突变率(50%),但是这真的只是为了展示。你可以让它低一点,比如1%。我的代码使唯一一个字母移动一个ASCII码位但是你可以让你的代码更激进一点。做实验,测试,然后学习。这是唯一的方法。

染色体: 总结

染色体是问题候选解决方案的表达。它们由表达本身(在我们的案例中,一个13个字符的字符串),一个成本或者适应性函数,配对("交叉")的能力,突变的能力组成。

我喜欢用OOP术语来思考这些东西。"染色体"类因此有下面这些属性:

属性:

  • 基因码

  • 成本值/适应值

方法:

  • 配对

  • 突变

  • 计算适应值

我们现在来看基因如何相互作用在GA谜题的最后一部分:"种群"。

种群

种群是一组染色体。种群通常保持一样的大小,但种群的平均成本值通常会随着时间进化的更好。

你可以选择自己的种群大小。我下面的程序我选择了20,但是如果你想你可以选择10或者100或者10000。它们有优势和劣势,但正如我到现在已经说了很多次一样:自己做实验然后学习它!

种群经历"世代"。一个通常的世代可能由这些组成:

  • 为每个染色体计算成本值/适应值分数

  • 按照成本值/适应值分数排序染色体

  • 杀死一定数目的最弱的成员--你选择将会死亡的染色体的数目

  • 配对一定数目的最强的成员--再一次,你选择如何做

  • 随机突变成员

  • 某种完整性测试 -- 例如,你如何确定何时这个问题被认为"解决"?

开始和完成

初始化一个种群是容易的。只需要用完全随机的染色体填充它。在我们的案例中,完全随机的字符串的成本分数是可怕的。我们的代码从平均成本30000开始。但这没问题--这就是进化的目的。这是我们在这里的原因。

知道何时停止种群进化有一点棘手。今天的例子非常简单:当你得到0成本停止。但它不是总是这样。有时候你不知道最小的可得的成本值。或者,如果你在使用适应性值而不是成本值,你可能不知道最大的可能的适应值。

在那些案例中,你需要指定一个完成准则。这可以是你想要的任何事,但这儿有一个建议你可以从这开始:

如果最好的得分在1000代内都没有改变,,那么停止算法,然后使用它作为答案。

像这样的准则可能意味着你没有得到全局最优,但是在很多情况你不需要得到全局最优。有时候"足够接近"真的就是足够好。

我将很快写另一篇文章关于GA(这一次用PHP)用一个稍微不同的问题,那一个将会有一个和上述相似的完成规则。现在就理解"足够近就是足够好"这个个观点可能有点困难,但我希望一旦你看到实际的例子你会相信我的话。

代码

最后!我喜欢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));
        }
};

简单吧。它只是一个类,构造函数接受一个字符串并设置成本,并且有一个辅助函数来制造一个新的随机的染色体。

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;
};

成本函数接受一个“模仿”字符串作为实参,找到它们在码点上的差值然后求它的平方。

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()

突变函数接受一个浮点数作为实参--代表染色体会突变的百分比机会。如果染色体将要突变,那么我们随机决定是从一个随机选中的字符码增加还是减去一个码点。我的进度实在是太快了以至于没法写一个合适的String.prototype.replaceAt方法,所以我在这里用了一个更简单的方法。

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方法作为一个辅助函数来依据成本值排序种群。

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()函数(没有在页面上展示)只是渲染输出到页面上,而且我设置了一个timeout在两个世代之间,以免系统失去响应。

注意在这个例子中我仅仅配对了头两个染色体。你也可以用不同的方法配对。

window.onload = function() {  
        var population  = new Population("Hello, world!", 20);
        population.generation();
};

上面的代码让系统开启,在实践中参看它吧。

它有点慢--但是这不是很困难去想出低效的部分在哪里。如果你足够聪明你当然可以使它像闪电一样快。像往常一样,我鼓励你自己去fork它然后实验学习。

注意,上面没有什么东西不可以用其它编程语言来做。但是你可能也期待这个,因为毕竟这是"基于所有语言的机器学习"。

学习愉快!这个系列的下一篇博客是ML in JS: k-nearest-neighbor.

这里也有一篇Genetic Algorithms Part 2,如果你想更深入的话你应该阅读它。

译者haoming尚未开通打赏功能