原文:https://www.burakkanber.com/blog/machine-learning-genetic-algorithms-in-javascript-part-2/
作者:Burak Kanber
翻译:王维强
今天我们将对遗传算法故地重游。如果还没读过第一部分,我强烈建议现在就去了解。这篇文章会跳过在第一部分讲到的一些基础概念,如果你是个新手当然要从那开始。
问题
如果你是个科学家而且被一家可恶的公司压迫,在你逃离前如果有机会从化学仓库中窃取1000磅(或公斤)的纯元素,然后卖掉以偿还公司所拖欠的薪水。
在给定每样元素的重量和价值的情况下,用什么方法能计算出在1000磅(或公斤)限制条件下拿走最多价值的东西呢?
这是一个背包问题,也是一个单一维度问题,意思是说只有一个重量约束条件。我们当然也可以引入体积维度使问题复杂化,不管怎样我们都需要从一个地方开始。需要注意的是在我们这个版本的问题中每种元素只有一块可拿,每块都标记了重量。有些背包问题会让你拿不限数量的白金或者最多三块黄金等等,但是我们这里只允许每样拿一个。
为什么这样的问题很棘手?我们会使用118种元素,如果使用暴力求解就要做2118或3.3*1035种不同元素的排列组合。
贪婪算法
在我们的方案中可以使用一个叫“贪婪算法”的程序,贪婪算法会攫取最大价值的物品并放置于背包中直到放满为止。
有时这个算法工作的很好,有时却会出问题。想象一下在库房中有一块黄金价值1000重量600磅,还有一块镉价值950重量300磅,另有一些元素价格很高但是重量很轻,贪婪算法会拿到金子而使得限重用完,导致整个背包价值偏低。
在我们给定的数据中,“幼稚”的贪婪算法会拿到价值3649重量998磅的物品。
在这一点上我们会想:“为什么不把每种元素每磅的价值标出来呢?” 的确,做这样的设定之后,实际上算法会工作的更好。
使用“权重”贪婪算法我们能得到价值4901重量969磅的物品。
所以我们要的数字就是:期望值$3,649,如果是 $4,901更好。
为什么贪婪算法对这类问题有效?因为该算法就是为解决类似“单位重量下最好价值”的问题,恰好也满足背包问题的解决方案。无论如何贪婪算法在价值与重量取值范围过大的情况下工作的并不好,比如价值或重量范围在1到100间该算法表现良好,如果范围扩大到1到500,则表现疲软。
对评论的回复:为什么贪婪算法在价值或重量取值范围较大时工作不好?
我们的遗传算法可能会随时间推移输给贪婪算法,但这没什么,遗传算法会随着问题的复杂度增加而表现突出,可是贪婪算法做不到这一点。
这里我们还是着眼于比较简单而且有人工干预的问题,不过要比之前的“Hello, World!”复杂一些,让我们开始吧。
与"Hello World"的关键区别
本次案例使用的问题与之前的“Hello, World!”问题的主要区别:
- 不能使用元素(基因)超过一次,"Hello, World!"问题中能而且必须使用多次。
- 在"Hello, World!" 案例中,我们知道字符串的长度必须为13,本例中却不知道最终元素的数目。
- 我们不知道本例中最高能达到的值,可能是$4,901也可能$10,000, 或者$23,304
染色体表达
在"Hello, World!"问题中,我们使用一个字符串代表染色体,突变就是随机改变其中的一个字符,交配就是把两个字符串截成两段重新再组,本例中我们需要做的有点不一样。
在我们的解决方案中需要用到比 "Hello, World!" 案例更讲究一点策略,因为我们不知道需要选择多少元素,我们不能使用一个固定的数目。
取而代之的是我打算使用一系列“位标记”,我们不必用实际的位标记,但是我的打算是把所有元素都罗列出来并用“表达”或“不表达”的记号标定每个元素。
我们的染色体看起来是这样的:
Helium: present Hydrogen: not present Lithium: not present
以此类推到其余118个元素,或者你也可以用位标记规则,如下:
10000011000001000100000010000010010010000
其中每个位代表一个元素,值表示该元素是否在背包里出现。
另外,如果我们允许每个元素出现多次,表现形式可以如下:
Helium: 0
Hydrogen: 4
Lithium: 2
像下面这样的表示方法就适合:
In Knapsack: Helium, Lithium, Lead, Tin
上面这种表达方式对配对来说比较困难,虽然你也能让它跑起来,但是这种感觉就像你跳起来钻进篮筐把它拉下来。结构性的数据比较适合这个问题。
我们会遇到一个比较特别的配对困难就是:在配对和突变之后我们需要确保每个元素在列表中最多只有一个。使用位标记方法能帮我们应付这个问题,但是这也是一个常见的缺陷。
这种常见的困难就是要确保在染色体中的的变化只发生一次,如果你对旅行商问题比较了解,是很容易想象混合两个方案重访一个城市两次的情景,同一个城市会出现在父方案的上半截同时也会出现在另一个父方案的下半截,所以这个城市可能会出在一个子方案出现两次,而在另一个子方案中没有出现。
超重种群
针对这个问题,我们会跟踪种群的三个属性:weight, value, and score.
Score 和值是一回事,有一点区别就是: score 计算超重种群。
你可能完全被超重种群迷惑了,这是很自然的直觉,因为超重种群的解决方案是不被接受的,但有一点好处,就是具体实践中我们并不想产生超重染色体:有时可能稍微超重(1001磅)的染色体具有很高的价值,这就需要回调一点使之落在重量范围内。
可能在那些超重的染色体中存在很多可能性,相比杀死它们,我们更愿意处罚它们使之能够继续被利用,但是不会把它们放在备选第一号位置。这就我们使用"score"的原因,如果比标重低,score就是总值,如果超重,无论如何,我们会每超一磅处罚50个点。至于是50还是其他,可随意变换。
从进化上来讲,这个“鼓励”承诺染色体减少些重量,所有的需求只是轻微的调整,没有必要扔掉潜在的很好的备选者。
在代码中我们将会着重于配对,突变和死亡的重要性。
代码
首先,先来看看数据集,我写了一个简单PHP程序给每个元素赋予重量和价值(区间1-500),然后输出成JSON格式,看起来如下所示:
"Hydrogen":{
"weight":389,
"value":400},
"Helium":{
"weight":309,
"value":380},
"Lithium":{
"weight":339,
"value":424},
"Beryllium":{
"weight":405,
"value":387},
"Boron":{
"weight":12,
"value":174},
然后我们定义三个快速简单的方法:
function length(obj) {
var length = 0;
for (var i in obj)
length++;
return length;
}
function clone(obj) {
obj = JSON.parse(JSON.stringify(obj));
return obj;
}
function pickRandomProperty(obj) {
var result;
var count = 0;
for (var prop in obj)
if (Math.random() > 1 / ++count)
result = prop;
return result;
}
length属性仅仅存在于javascript 数组,所以我们需要为object创建这样一个功能。
我们创建一个克隆函数能够确保我们的元素对象不需要经过其引用对象。最后,我们写了一个函数专门处理随机选择对象属性,这是模仿PHP的array_rand函数,目的就是随机返回一组key。
染色体函数
var Chromosome = function(members) {
this.members = members;
for (var element in this.members)
{
if (typeof this.members[element]['active'] == 'undefined')
{
this.members[element]['active'] = Math.round( Math.random() );
}
}
this.mutate();
this.calcScore();
};
Chromosome.prototype.weight = 0;
Chromosome.prototype.value = 0;
Chromosome.prototype.members = [];
Chromosome.prototype.maxWeight = 1000;
Chromosome.prototype.mutationRate = 0.7;
Chromosome.prototype.score = 0;
染色体构造器需要一个参数“members”,是个对象集合,在本例中我们将会把原始的元素数据集传递进去来生成一只新的染色体,或者把配对的结果传递进去。
如果“active”属性没有定义,该构造器会随机地创建该属性,结果就是产生了一条随机预先配置好的染色体。
原型也会指定一些默认值,突变率(mutationRate)就是染色体突变的概率。
Chromosome.prototype.mutate = function() {
if (Math.random() > this.mutationRate)
return false;
var element = pickRandomProperty(this.members);
this.members[element]['active'] = Number(! this.members[element]['active']);
};
突变方法很像在“Hello, World!”中使用的那样,一旦染色体突变发生,我们就简单地随机挑选一个元素并开关其“active”属性。
这里我使用数字类型代替布尔类型,在构造器中把随机数字转换成布尔类型更具语义性,我没这么做是因为我已经把代码粘贴到这篇文章里了。
Chromosome.prototype.calcScore = function() {
if (this.score)
return this.score;
this.value = 0;
this.weight = 0;
this.score = 0;
for (var element in this.members)
{
if (this.members[element]['active'])
{
this.value += this.members[element]['value'];
this.weight += this.members[element]['weight'];
}
}
this.score = this.value;
if (this.weight > this.maxWeight)
{
this.score -= (this.weight - this.maxWeight) * 50;
}
return this.score;
};
calcScore方法从细小的优化开始:如果已经计算过score,就使用缓存过的,这是个不错的方法,不必担心在染色体生命周期中哪个点是用来计算score。
然后查看所有的active元素并把它们的价值和重量累计起来,对于那些超重者处罚50个点。
Chromosome.prototype.mateWith = function(other) {
var child1 = {};
var child2 = {};
var pivot = Math.round( Math.random() * (length(this.members) - 1) );
var i = 0;
for (var element in elements)
{
if (i < pivot)
{
child1[element] = clone(this.members[element]);
child2[element] = clone(other.members[element]);
}
else
{
child2[element] = clone(this.members[element]);
child1[element] = clone(other.members[element]);
}
i++;
}
child1 = new Chromosome(child1);
child2 = new Chromosome(child2);
return [child1, child2];
};
在"Hello, World!"例子中,我们使用中间点为分界点来配对两个染色体,但在本例中,我们随机选择分界点。
这会在系统中增加一些随机性来回避局部最优问题。
一旦选定分界点并从父染色体产生了两个孩子,然后结合,随后使用染色体构造器生成新的染色体并返回它们。
种群
var Population = function(elements, size)
{
if ( ! size )
size = 20;
this.elements = elements;
this.size = size;
this.fill();
};
Population.prototype.elitism = 0.2;
Population.prototype.chromosomes = [];
Population.prototype.size = 100;
Population.prototype.elements = false;
种群构造器非常直白:构造器接收元素对象列表和期望的种群尺寸,我们也定义了一个“elitism”参数作为染色体中的数据在代际传递间存活的概率。
Population.prototype.fill = function() {
while (this.chromosomes.length < this.size)
{
if (this.chromosomes.length > this.size / 3)
{
this.chromosomes.push( new Chromosome( clone(this.elements) ) );
}
else
{
this.mate();
}
}
};
我们将使用填充的方法初始化种群,当孱弱的染色体被淘汰后也是用该方法重整种群。如何决定是否需要随机生成染色体或者用配对填充种群? 如果我们的种群尺寸是20,前6个染色体是随机的,余下的是并且通过配对产生。如果种群尺寸永远低于30%(可能归功于死亡或精英化),新的随机染色体将会被产生出来直到种群数量足够支撑新的婴儿能通过配对产生出来。
没错,'this.chromosomes.length' 在循环里是个糟糕的形式,如果你想使用大的种群尺寸或者想高度优化,正确的方法是把长度(length)缓存起来。
Population.prototype.sort = function() {
this.chromosomes.sort(function(a, b) { return b.calcScore() - a.calcScore(); });
};
Population.prototype.kill = function() {
var target = Math.floor( this.elitism * this.chromosomes.length );
while (this.chromosomes.length > target)
{
this.chromosomes.pop();
}
};
上面的排序功能只是一个助手,注意,我们使用calcScore方法代替直接访问score属性。如果score还没有被计算出来了,现在就做,否则我们直把calcScore作为访问器直接用。
排序之后,kill 方法从列表底部移除孱弱的染色体,直到符合精英化的值为止。
Population.prototype.mate = function() {
var key1 = pickRandomProperty(this.chromosomes);
var key2 = key1;
while (key2 == key1)
{
key2 = pickRandomProperty(this.chromosomes);
}
var children = this.chromosomes[key1].mateWith(this.chromosomes[key2]);
this.chromosomes = this.chromosomes.concat(children);
};
mate方法永远在kill方法之后被调用,所以只有种群中的精英染色体允许被复制(本例中,那些最好的20%). 和只配对两个最好的染色体(如 "Hello, World!" 案例)相比,这里随机选取任意两个染色体进行配对,没有意外发生的话染色体不会与自身配对。
这样就增加更多的随机性,避免了最前面的两条染色体在多个子代中始终保持不变的情况,在"Hello, World!"案例中我们有时能看到这一点。
Population.prototype.generation = function(log) {
this.sort();
this.kill();
this.mate();
this.fill();
this.sort();
};
现在我们定义"generation". 先按score排列染色体,然后淘汰掉孱弱的,接下来是比较有趣的配对(mate)和填充(fill)。我们调用mate()方法的原因是更保险一些:如果精英化参数小于0.3,我们在想在种群被新的随机染色特污染之前至少配对一次。这完全依赖于精英化的值,如果使之大于0.3,你就必须额外调用mate(),因为fill()会替你做,如果你的精英化值只是0.2,那么我们必须运行一次只在精英间的配对程序,而不让通过fill() 函数产生的那些随机染色体参与配对。最后,在生成结束之时再运行一次排序程序。
运行和停止
我曾在 "Hello, World!" 的例子中描述过:如果我们在过程中不知道最好的可能分值,我们就不知道什么时候该停止。从技术上来说我们可以使用的停止策略就是:产生了100(1000或10000)次的繁殖之后并没有什么进化提升,就应该停止。我们称这个数值为“临界值”或“停止临界值”。
Population.prototype.run = function(threshold, noImprovement, lastScore, i) {
if ( ! threshold )
threshold = 1000;
if ( ! noImprovement )
noImprovement = 0;
if ( ! lastScore )
lastScore = false;
if ( ! i )
i = 0;
if (noImprovement < threshold)
{
lastScore = this.chromosomes[0].calcScore();
this.generation();
if (lastScore >= this.chromosomes[0].calcScore())
{
noImprovement++;
}
else
{
noImprovement = 0;
}
i++;
if (i % 10 == 0)
this.display(i, noImprovement);
var scope = this;
setTimeout(function() { scope.run(threshold, noImprovement, lastScore, i) }, 1);
return false;
}
this.display(i, noImprovement);
};
run方法是一个交互方法,可以舍去,在这里出现的原因是在这个例子中,在快速循环的间歇写数据到DOM是不能工作的,这是DOM本身的属性行为决定的,DOM的更新必须等待执行完成结束之后才开始。
受制于DOM的限制,我们使用较短的setTimeout方法去运行run函数调用自身,直到运行完成。通常情况下,无论如何,该方法只能使用while loop取代调用自身,但是这样一来你就不得不依仗console.log及时显示结果或等着循环结束。
除了DOM混乱的表现,run方法还是非常直接明了的,我们把最后子代的成绩和目前子代的成绩做比较,如果有提升,那么就重置noImprovement计数器。如果计数器的值达到了我们之前设定的临界值,那么就停机。
没有给出的是一个简单的现实方法,目的就是把结果打印到页面中的一个表格里。我们每隔10代调用一次,在结束的时候再调用一次显示最终结果。