這一節的標題是
9.5 Customizing Genetic Algorithms
因為方格子標題字數限制,所以沒完整顯現
GA的步驟很單純,所以在使用GA解決不同的問題時,基本上程式需要修改的部分很少,像挑選、繁殖等核心步驟,是完全不需要修改的。事實上,不管是要用GA解決什麼樣的問題,只把三個關鍵部分調整、修改一下就可以了。接下來就來看看,這三個關鍵部分,要怎麼因應問題來調整、修改。
Key 1: The Global Variables
族群規模及突變率是影響GA行為甚鉅的兩個參數。那這兩個參數要用多大的數值才能得到最好的效果呢?這問題沒有標準答案。不過,倒是有些基本的方向可供參考。
族群的規模大小,會對GA的行為有什麼影響呢?簡單來說,族群的規模越大,越容易在比較少的世代數量下演化出解答。既然如此,那是不是一律都用很大的族群就好了呢?恐怕不是這樣的!因為族群規模越大,每個世代所需處理的個體數量就會越多,處理所需的時間也會越長。使用比較小規模的族群,雖然需要演化的世代數量比較多,但因為每個世代所需處理的個體數量比較少,所以演化出解答的時間,並不見得會比大規模族群所需的時間來得長。因此,當想加快演化速度時,可別一味地加大族群規模,而應該試著調整另一個對GA效能也有顯著影響的參數:突變率。突變率的範圍為0~1;突變率越大,代表演化的隨機性越高。突變率為0時,代表演化是確定性的;能不能演化出解答,完全靠運氣。以電腦猴打字的例子來說,如果一開始的時候所有正確的字元都存在於族群的元素中,那很快就能演化出正確的答案;如果不是這樣的話,那演化到地老天荒也演化不出解答來。至於突變率為1,則代表系統的演化完全是隨機的;雖然最終可以得到解答,但有可能所需的時間會讓人無法接受。所以,想減少演化的時間,也別一味地加大突變率,那有可能會因為引入過多的隨機性,反而使得演化時間變長。
總之,族群規模和突變率並非越大越好,當然也不是越小越好。這兩者應該互相搭配、調整,才能有效地提高GA的演化效能。
Key 2: The Fitness Function
適應度函數是否設計得當,是GA能否成功演化出解答的關鍵。道理很簡單,因為適應度函數的作用就在告訴GA,演化有多成功、離目標有多遠,這樣GA才會朝目標所在的方向演化,最後也才能到達目標所在的位置。
設計適應度函數時,最主要的目標,是要能用數值清楚地告訴GA演化有多成功。那要怎麼設計呢?這沒有標準答案;不過有些設計技巧倒是可以學一下,除了可以避免在設計時踩雷,設計出效果不好的適應度函數,另外也可以藉由各種設計技巧,提高適應度函數的效果。
在設計適應度函數時,應該要避免鑑別度過低的情形;這也就是說,好、壞之間的分數差距不要太小,免得看不出來好壞之間的差異。就以電腦猴打字的例子來說,如果目標是要演化出含有「to be or not to be」這個字串,因為共有18個字母,所以如果把適應度函數定為
fitness = 在正確位置上的正確字元數量 / 總字元數量
這時每相差一個正確字元,適應度函數的值就會有
1 / 18 = 0.0556
的差距。可是,如果目標是要演化出一個由1000個字元所構成的字串,那每相差一個正確字元,適應度函數的值就只會有
1 / 1000 = 0.001
的差距。同樣是只差了一個正確字元,可是適應度函數值的差距,確足足相差了將近56倍。所以,如果目標是要演化出一個由1000個字元所構成的字串,那使用這樣子的適應度函數,其實並不是那麼的恰當。
要避免上述的情況,方法很簡單,把適應度函數改成
fitness = 在正確位置上的正確字元數量
就可以了。這樣子不管目標是要演化出多長的字串,每相差一個正確字元,適應度函數值的差距就都是1。
如果這樣還不夠,那就把適應度函數定為
fitness = (在正確位置上的正確字元數量)2
這樣每相差一個正確字元,適應度函數值的差距會更大。例如,正確字元數量是5和6的兩個個體,雖然正確字元數量只相差1,但其適應度函數值分別是25和36,足足相差了11。
上述的適應度函數,它的值是以二次增長(quadratic growth)的方式增加,而原先的適應度函數,則是以線性增長(linear growth)的方式增加。另外還有一種增加速度更快的是指數增長(exponential growth)。例如
fitness = 2在正確位置上的正確字元數量
同樣正確字元數量是5和6的兩個個體,其適應度函數值分別是32和64,相差了32,比二次增長時的差距還要大很多。
Exercise 9.8
讓適應度函數呈二次增長。
修改DNA
類別的calculate_fitness()
方法:
def calculate_fitness(self, target):
score = 0
for i in range(self.length):
if self.genes[i] == target[i]:
score += 1
self.fitness = score*score
Population
類別的target_score
屬性也要跟著修改成
self.target_score = len(target)**2
適應度函數很難一體適用,通常都必須針對應用的場景或要解決的問題重新設計。例如,用於演化電腦猴打字的適應度函數,就不能用於讓老鼠演化出能找到食物的最好方式,因為去數有幾個正確字元的適應度函數,是不可能讓老鼠演化出找到食物的最好方式的;比較好的設計,應該是像這樣:
fitness = 老鼠和食物之間的距離
另一個應用的場景是電玩中NPC的控制。例如足球電玩中,假設進行的是PK戰,守門員是由玩家所控制,而進攻球員則是由電腦控制。每個進攻球員都具有許多用來決定要怎麼踢球的參數。假設我們把適應度函數設計成
fitness = 總進球數
這樣總進球數越高的球員,其參數會越有機會保留下來而在後續的比賽中發揮功效。這也就是說,隨著一場又一場的比賽,由電腦所控制的進攻球員,會越來越適應玩家的防守策略,並演化出更能進球的進攻策略;即便換了個玩家以完全不同的策略來防守,系統也會察覺適應度函數值下降,從而演化出新的進攻策略來讓適應度函數值上升。這種能讓系統自動調整以適應外在變化的能力,也就是演化式運算的威力之所在。
總之,適應度函數如果不能正確地評估出族群中個別元素的演化有多成功,那族群將不會朝我們所希望的方向進行演化;要針對不同的應用場景或要解決的問題來設計專屬的適應度函數,這樣才能確保演化能夠順利進行,並獲得我們想要的結果。
Key 3: The Genotype and Phenotype
最後一個關鍵部分是關於基因型與表現型的設計;這牽涉到要以怎樣的方式將系統屬性編碼,以及如何將想要表現在外的東西給轉成數字。
在電腦猴打字的例子中,那些放在list中的字元就是編碼後的系統屬性,也就是基因型,而顯示在畫面上的字串,則是表現型。這個例子很簡單、很單純,很容易就能看出該怎麼設計基因型和表現型。但事情並不總是這麼簡單。
在前面談到關於足球PK戰電玩的適應度函數設計時提到,每個進攻球員都具有許多用來決定要怎麼踢球的參數,但並沒有提到這些參數究竟是什麼。事實上,這些參數就是基因型。設計基因型時,就是要決定這些參數是什麼東西,以及要用怎樣的方式來將這些參數編碼;這很明顯不是件簡單的工作,而且也沒有標準答案,一切就看你想要怎麼設計。
接下來,就用個實際的例子來看看,基因型和表現型的設計過程。
在設計類別時,我們會設定一堆變數,例如
class Vehicle:
def __init__(self):
self.max_speed = ?????
self.max_force = ?????
self.size = ?????
self.separation_weight = ?????
:
:
想要讓這些變數能夠在GA中進行演化,就只需要把它們轉變成DNA
類別中的方法,如crossover()
、mutate()
等,可以使用的list就可以了。要達到這個目的,一個常見的做法是使用由0~1的浮點數所構成的list,像這樣:
class DNA:
def __init__(self, length):
self.length = length
self.genes = [random.random() for _ in range(self.length)]
這裡要注意一下,之所以選用0~1間的浮點數,就只是因為比較好處理;使用其他範圍的浮點數,也是可以的。透過簡單的計算,在某個範圍的浮點數,可以很容易就映射到不同的範圍中;這個可以參考〈The Nature of Code閱讀心得與Python實作:0.6 A Smoother Approach with Perlin Noise〉的〈Noise Ranges〉小節。
另一個要注意的地方是,我們把原始的遺傳數據,也就是基因型,以及這些數據會外顯表現出來的東西,也就是表現型,分別放在不同的類別中。以DNA
、Vehicle
這兩個類別來說,DNA
類別是基因型,它裡頭就是一堆數據;而Vehicle
類別則是表現型,它涉及如何讓基因型的那一堆數據表現在外,最終轉化成動畫以及視覺效果。
藉由在Vehicle
類別中納入DNA
類別的實例,我們可以將基因型和表現型聯繫起來:
class Vehicle:
def __init__(self):
# 將DNA物件嵌入Vehicle類別
self.dna = DNA(4)
self.max_speed = dna.genes[0]
self.max_force = dna.genes[1]
self.size = dna.genes[2]
self.separation_weight = dna.genes[3]
:
:
基因型並不一定是數字,而可以是任何資料型態。例如,設計具有一系列推進器的火箭時,可以把每個推進器都看成是向量。這時,基因型就可以設計成是向量物件:
class DNA:
def __init__(self, length):
self.length = length
self.genes = []
for _ in range(self.length):
r = random.uniform(0, 10)
theta = random.uniform(0, 360)
# 大小介於0~10,方向為任意方向的推力
thruster = pygame.Vector2.from_polar((r, theta))
self.genes.append(thruster)
至於表現型,則可以設計成一個Rocket
類別:
class Rocket:
def __init__(self):
self.dna = ?????
:
:
把基因型和表現型分別放在兩個不同的類別中,最大的好處是,在處理不同的問題或在不同的場景中,放置基因型的那個類別,只要調整、修改放在list中資料的類型,就可以拿來重複使用,不需要整個重新設計或改寫。例如,在火箭的這個例子中,在DNA
類別部分,我們就只修改了genes
這個list中所存放的資料,而沒有重新設計整個類別。