使用遺傳算法實現迷宮遊戲(genetic maze)

強烈推薦一本書 《遊戲編程中的人工智能技術》(AI.Techniques.for.Game.Programming).(美)Mat.Buckland

一、緣起

在之前的c印記系列當中有有一個迷宮小遊戲,算是一個關於數組應用的例子。 其中有通過接收按鍵(人工操作)的方式來走出迷宮,也有使用遞歸算法或非遞歸算法的方式來實現自動(AI操作)走出迷宮。

後來我對近兩三年比較火的人工智能,機器學習,深度學習之類的比較感興趣了。於是乎,我找了很多書籍或網上的文章來看。但基本上都是兩個類別的,其中一類就是一上來就是甩出一堆讓人看得眼花繚亂的數學公式,工作好幾年了,大學時學的高的數學,線性代數,微積分,概率統計之類的東東基本都忘得差不多了,看起來很是頭疼。 還有一類就是照搬各種框架的官方文檔,這其中最多應該就算TensorFlow框架了,但他是Python實現的,而本人工作這麼多年,絕大多數時間都是使用的c/c++, 雖然Python也瞭解一些,但還是不太習慣看Python的應用,尤其是很多將Python用的很複雜,在可讀性方面總是讓人一個頭兩個大的。

再後來無意當中看到一本書籍《遊戲編程中的人工智能技術》,雖然我不是做遊戲開發的,但我始終認爲技術方面有很多地方都是共通的,他山之石可以攻玉。 於是乎,我就稍微的看了一下,發現真的很適合我這個初學者,淺顯易懂。這本書裏面關於智能,它寫了兩方面的東西,一個是遺傳算法,一個是神經網絡。 就像作者在書中所說的那樣,他寫這本書就是想讓讀者看了之後能夠有一種 “啊哈”的感覺(我覺得完整一點的表達應該是:“啊哈,原來如此”之類的)。 這篇文章,說的就是那本書中提到的第一部分: 遺傳算法

二、遺傳算法簡介

在百度百科中對遺傳算法的解釋如下:

遺傳算法(Genetic Algorithm)是模擬達爾文生物進化論的自然選擇和遺傳學機理的生物進化過程的計算模型,是一種通過模擬自然進化過程搜索最優解的方法

至於更詳細的遺傳算法知識點,有興趣的可以在網上去搜索,應該會有很多資料。這裏就不在鋪開來說明了,說太細就變成一本書了。

2.1 基本組成

遺傳算法既然是模擬生物進化的過程,那它的組成也和生物進化中的專業名詞相似。

  • 種羣:由n個個體組成的一個物種或羣體
    兔子種羣

  • 適應性:個體評價,個體對環境的適應度

  • 算子(操作函數):

    • 選擇:就像自然界的物競天擇一樣,根據某種規則在種羣中選擇出父代個體,以進行繁殖,產生出新的子代。並淘汰掉沒被選擇的個體。
      選擇
    • 雜交:當父代產生子代的過程中,父代中父親和母親的基因片段根據特定的規則發生了互換。
      雜交
    • 變異(或突變):在產生子代的過程中,子代的基因片段根據特定的規則發生了變異。
      變異
  • 鹼基:在遺傳算法中不一定存在,它是組成基因的基本單元。

  • 基因:在遺傳算法中,基因就是最基本的信息編碼單元。

  • 染色體:由n個基因組成,是需要求解的問題的一個潛在的可行解。

  • 染色體組:由n個染色體組成(每一個染色體代表一個不同的候選解)。

2.3 基本原理/流程

遺傳算法的基本流程如下:

遺傳算法流程

上圖中紅色虛線包裹起來的部分就是繁殖新子代所要進行的基本過程。另外,在判斷收斂標準的同時,也需要判斷遺傳進行了多少代了,可以預設一個遺傳代的最大數量,以防止死循環(當找不到能達到標準的解時)。

2.4 特點與案例

“遺傳算法不保證一定能得到解,如果有解也不保證找到的是最優解,但只要採用的方法正確,你通常都能爲遺傳算法編出一個能夠很好運行的程序。遺傳算法的最大優點就是,你不需要知道怎麼去解決一個問題;你需要知道的僅僅是,用怎麼的方式對可行解進行編碼,使得它能能被遺傳算法機制所利用”————摘自《遊戲編程中的人工智能技術》第三章第三節

以上基本上就概括了遺傳算法的特點,還有一個比較經典的“袋鼠跳”問題。在說這個問題之前,需要先了解幾個基本的概念:極大值、最大值、局部最優解、全局最優解。

已知一元函數:
一元函數

其波形如下圖所示:
波形圖

  • 極大值:在一個小鄰域裏面左邊的函數值遞增,右邊的函數值遞減,那麼這個點就是一個極大值。也就是說上圖中每一個紅點的“波峯”都是一個極大值。
  • 最大值:就是在所有極大值當中,最大的那一個。
  • 局部最優解:與極大值對應,也就是說每一個“波峯”就是一個局部最優解。
  • 全局最優解:與最大值相對應。

袋鼠跳案例:

假設,上面一元函數的波形圖就是廣袤的羣山,“波峯”就代表山頂,“波谷”就代表山谷。在這些羣山之中生活着很多的袋鼠,初始情況如下圖所示(紅色的點代表袋鼠):
袋鼠跳_初始

現在,由於全球氣候變暖,海平面上升,海拔低的地方逐漸的被海水淹沒。也就是說,生活在海拔越高的地方,活的就久。 在低海拔的生活的袋鼠就會逐步走向滅亡(原本這裏的選擇條件是說低海拔的袋鼠會被射殺,我這裏換了一種說法,但原理是一樣的)。

因此,就開啓了一場自然選擇與物種進化的旅程。

經過幾代進化之後,低海拔的袋鼠都被海水淹沒而滅絕了,高海拔的仍然在繼續繁衍。
袋鼠跳_幾代之後

當然,這裏並不是簡單的直接將低海拔的袋鼠抹掉,因爲在進化的過程中,低海拔的有些袋鼠會因爲某些原因向高海拔遷徙(就當這部分是聰明有遠見的袋鼠吧),同樣的生活在高海拔的地方袋鼠也有可能反而向低海拔的地方遷徙。

最理想的情況是(或者說最優解),到最後大多數袋鼠都遷徙到最高峯生存繁衍了。如下圖所示:
袋鼠跳_最理想

前面已經說到了,遺傳算法並不保證一定能夠得到解,如果有解也不保證是最優解。在袋鼠跳的案例中,袋鼠們也不總是能夠幸運的跳到最高的山峯上,有時候,他們以爲的他們跳到了最高峯,但其實並不是,有可能只是如下圖所示的一個局部高峯而已:
袋鼠跳_局部最優

三、遺傳算法走迷宮

看過c印記系列中的就知道,這個由數組作爲地圖的迷宮是一個二維的平面,角色的移動方式有四種:前,後,左,右(或者東南西北,上下左右都可以),每次只能移動一步,遇到障礙物或牆壁,只能改變方向,不能穿透障礙物。這就是基本的幾個規則。

接下來,就是根據第二章中說到的遺傳算法的組成,依次將迷宮小遊戲的元素套入到遺傳算法中去。

3.1 鹼基,基因,染色體,染色體組

上面說到迷宮小遊戲的移動方式有四種,如果我們使用二進制數值來表示迷宮小遊戲的基因的話,就需要兩位二進制數來表示。

二進制編碼基因

這部分也是《遊戲編程中的人工智能技術》中第三章第四節中的例子:幫助Bob回家當中說到的東西。在這個迷宮小遊戲當中,雖然在進行遺傳算法的時候也有使用鹼基層的部分,但在這裏爲了簡單直接使用十進制來表示一個基因,併爲其定義了一個數據類型

typedef char gene_t; /** the type of gene */

鹼基,基因,染色體,染色體組的包含關係如下所示:

基因包含關係

從這張圖可以知道,染色體組由n條染色體組成,染色體由n個基因組成,基因由n個鹼基組成。也就是說在定義數據結構體時需要知道各自的數量。 但是因爲這些變量的值都是相同的(比如染色體組中,每一條染色體內包含的基因數量都是相同),所以就可以將這些表示數量的變量提取到公共部分(比如,染色體中表示當前染色體中包含的基因數量的變量就可以提取出來)。這樣可以節省不必要的內存浪費。具體的數據結構定義如下:

typedef struct genome_s 
{
//    int gene_length; /** how many bits per gene */
//   int chromosome_length; /** how many count of gene per chromosome */
    gene_t genes[CHROMOSOME_LENGTH]; /** chromosome (consist of a number of genes) */
    double fitness; /** the score of fitness */    
}genome_t;

從上面這個數據結構可以看出,這裏其實並沒有仔細區分染色體和染色體組(或者說,這裏每個染色體組裏面就只有一條染色體)。另外一點是,這裏使用數組來存放基因,只是爲了簡單(因爲使用c語言寫的,沒有方便的vector之類的容器),如果要編寫靈活性和通用性較好的遺傳算法程序,可能就不太恰當了。

然後在上面的結構體中海油一個變量“double fitness;”,因爲這裏一個染色體組就表示一個個體,每個個體都需要一個變量來表示這個個體對環境的適應度。

接下來就是整個種羣的數據結構的定義了

typedef struct population_s
{
    int pop_size; /** the size of population(how many genomes per population) */
    genome_t genomes[POPULATION_SIZE]; /** the population of genomes */

    double crossover_rate; /** the rate of genome crossover */
    double mutation_rate; /** the rate of genome mutation */

    /** because all genome have the same of gen_length and chromosome length, 
     * so we move them to here from structure of genome_t to save memory */
    int gene_length; /** how many bits per gene */
    int chromosome_length; /** how many count of gene per chromosome */

    //////////////////////////////////////////////////////////////////////////
    int fittest_genome; /**the genome which have best score of fitness */
    double best_fitness_score; /** the best of score of fitness */

    double total_fitness_score; /** the total of all score of fitness */

    int generation; /** mark the count of genetic generation */

    unsigned int is_running; /** 1: running, 0: not. to avoid  endless loop */
}population_t;

上面的數據結構的定義中,都有相應的註釋,這裏就不多說了。

最後是地圖部分的數據結構定義了,其原理和結構和c印記中是差不多了,也不再多做解釋。只是二維數組的行和列的大小不太一樣(是截取的《遊戲編程中的人工智能技術》中第三章第四節中的例子的數組結構)。

static const int g_maze_map[MAZE_ROW][MAZE_COLUMN] =
{ 
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 0, 1, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 1,
8, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 1,
1, 0, 0, 0, 1, 1, 1, 0, 0, 1, 0, 0, 0, 0, 1,
1, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 1,
1, 1, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 1,
1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 1, 0, 1,
1, 0, 1, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 5,
1, 0, 1, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 
};

3.2 迷宮小遊戲的遺傳算法算子

在這一節就根據第二章中的遺傳算法的基本流程來依次說明各個算子函數。

3.2.1 判斷驗收標準

初始化部分就不必細說了,首先來看 “收斂標準是否滿足?”步驟。

這一步在代碼中的 updateFitnessScore 函數中進行,具體代碼如下:

static void updateFitnessScore(population_t* population)
{
    int i;
    int pop_size = population->pop_size;
    int move_steps[CHROMOSOME_LENGTH];
    genome_t* genome = NULL;
    int chromosome_length = population->chromosome_length;

    int fittest_genome = 0;
    double best_fitness_score = 0;
    double total_fitness_score = 0;


    //更新適應性分數,並檢測到目前爲止適應度最好的個體
    for (i = 0; i < pop_size; ++i)
    {
        genome = &(population->genomes[i]);
        //將個體的基因譯碼爲具體的移動步驟
        genomeDecode(genome, move_steps, chromosome_length);

        //通過試走一遍來確定當前個體最終的適應性分數
        genome->fitness = mazeTestPlay(move_steps, chromosome_length);

        
        //更新總適應性分數
        total_fitness_score += genome->fitness;

        //如果當前個體的適應性分數是最好的,那就當前個體在種羣中的索引值保存起來
        if (genome->fitness > best_fitness_score)
        {
            best_fitness_score = genome->fitness;

            fittest_genome = i;

            //檢測是否已經到達迷宮出口
            if (genome->fitness == 1)
            {
                //如果已到達迷宮出口,就停止遺傳算法的運行
                population->is_running = 0;
            }
        }

    }//next genome

    population->fittest_genome = fittest_genome;
    population->best_fitness_score = best_fitness_score;
    population->total_fitness_score = total_fitness_score;
}

經過上面的函數之後,如果population->is_running 仍然等於 1,表示還未達到收斂標準,需要繼續繁衍下一代。

3.2.2 執行選擇算子

這時候就需要執行選擇算子了。在遺傳算法中有很多種選擇算子,這裏只是使用了一種比較簡單的選擇算子,叫做 輪盤賭 選擇。如果有對此感興趣的可以自行在網上去搜索相關信息。

** 輪盤賭選擇**

又稱比例選擇方法.其基本思想是:各個個體被選中的概率與其適應度大小成正比。但這不保證適應性分數最高的成員一定能選入下一代,僅僅說明它有最大的概率被選中。就像下圖的餅圖一樣,每一個塊,代表一個個體的適應度,當最終的選擇點落入哪一個塊,就表示哪一個個體被選中。

輪盤賭餅圖

具體操作如下:

(1)計算出羣體中每個個體的適應度f(i=1,2,…,M),M爲羣體大小;

(2)獲取選擇點;

(3) 然後依次將個體的適應度累加,直到累加個體n之後,總的適應性分數 > 選擇點 時就表明個體n被選中;

依次累加這個起始點也可以使用隨機函數來確定,在本例中爲了簡單,每次都是從第0個個體開始累加的。

具體的函數實現如下:

genome_t* rouletteWheelSelection(population_t* population)
{
    /** 使用一個隨機數(0.0 ~ 0.9999999...) 來乘以 總適應性分數得到選擇點 */
    double fSlice = randFloat() * population->total_fitness_score;
    double total = 0.0;
    genome_t* genomes = population->genomes;
    int pop_size = population->pop_size;

    int	selected_genome = 0;

    for (int i = 0; i < pop_size; ++i)
    {

        total += genomes[i].fitness; /** 累加適應性分數 */

        if (total > fSlice)
        {/** 如果累加的適應性分數大於選擇點時,表示得到了被選擇的個體,記錄下索引值並推出循環 */
            selected_genome = i;
            break;
        }
    }

    return &genomes[selected_genome]; /** 根據索引得到被選中個體,並將其返回 */
}

3.2.3 執行雜交算子

本例子中使用的也都是簡單的雜交算子,其基本思路就是:

  • 確定一個雜交率,即,父代基因發生交換的概率。《遊戲編程中的人工智能技術》一書中說“實驗表明這一數值通常取爲0.7左右是理想的,儘管某些問題領域可能需要更高一些或較低一些的值。” 所以本例也是選擇的0.7

  • 產生一個隨機數,並與雜交率比較,如果隨機數小於等於(<=)雜交率,也就表明在需要進行交雜的範疇之類。

  • 產生一個隨機數來作爲基因交換的起始點(本例中使用的是單點雜交)將起始點到染色體末尾的所有基因進行交換。

具體函數如下所示:

void geneticCrossover(gene_t mum[], gene_t dad[], gene_t baby1[], gene_t baby2[],double crossover_rate, int chromosome_length)
{

    if ((randFloat() > crossover_rate) || (geneCompare(dad, mum, chromosome_length)))
    {/** 如果產生的隨機數大於雜交率,或者父代中的 父親和母親是相同的,就不需要進行雜交,直接返回 */
        baby1 = mum;
        baby2 = dad;

        return;
    }

    /** 隨機產生一個基因交換的起始點 */
    int cp = randInt(0, chromosome_length - 1);

    /** 按正常順序將父代基因拷貝到子代基因中去 */
    for (int i = 0; i < cp; ++i)
    {
        baby1[i] = mum[i];
        baby2[i] = dad[i];
    }

    /** 從交換起始點開始,將父親和母親的基因交換拷貝到子代基因中去 */
    for (int i = cp; i < chromosome_length; ++i)
    {
        baby1[i] = dad[i] ;
        baby2[i] = mum[i];
    }
}

3.2.4 執行突變算子

本例單子的變異算子也比較加單,就是在將符合變異的鹼基(本例當中鹼基是一個二進制位),進行翻轉,即0變1,1變0。
具體函數如下:

void geneMutate(gene_t gene, int gene_length, double mutation_rate)
{
    int i;

    for (i = 0; i < gene_length; i++)
    {
        
        if (randFloat() < mutation_rate)
        {/** 如果產生的隨機數小於突變率,也就表明在執行突變的範圍之內 */
            /** 將當前基因的第 i 個鹼基(二進制位) 進行翻轉 */
            gene ^= ((1 << (i - 1)));
        }
    }
}

void geneticMutate(gene_t genes[], int chromosome_length, double mutation_rate)
{
    int i;
    for (i = 0; i < chromosome_length; i++)
    {
	/** 依次對每一個基因嘗試進行突變操作 */
        geneMutate(genes[i], GENE_LENGTH, mutation_rate);
    }//next bit
}

3.2.5 Epoch函數

Epoch函數就是組織整個遺傳算法的函數,本例的實現如下:

unsigned int populationEpoch(population_t* population)
{
    updateFitnessScore(population); /** 更新適應性分數,並檢查是否達到收斂標準 */

    if (population->is_running == 1)
    {/** 如果沒有達到收斂標準,就產生新的子代 */
        //Now to create a new population
        int baby_count = 0;
        int pop_size = population->pop_size;
        int chromosome_length = population->chromosome_length;

        genome_t* org_genomes = population->genomes;
    
        //create some storage for the baby genomes 
        genome_t baby_genomes[POPULATION_SIZE]; /** 子代種羣 */

        while (baby_count < pop_size)
        {
            /** 使用輪盤賭的方式在父代中選出父親和母親 */
            genome_t* mum = rouletteWheelSelection(population);
            genome_t* dad = rouletteWheelSelection(population);

            /** 執行雜交算子 */
            genome_t baby1, baby2;
            geneticCrossover(mum->genes, dad->genes, baby1.genes, baby2.genes, population->crossover_rate, chromosome_length);

            /** 分別對兩個子代執行突變算子 */
            geneticMutate(baby1.genes, chromosome_length, population->mutation_rate);
            geneticMutate(baby2.genes, chromosome_length, population->mutation_rate);

            /** 將新產生的子代存儲到子代種羣當中 */
            baby_genomes[baby_count++] = baby1;
            baby_genomes[baby_count++] = baby2;
        }

        //copy babies back into starter population
        memcpy(org_genomes, baby_genomes, sizeof(genome_t) * POPULATION_SIZE);

        //increment the generation counter
        population->generation++;
    }
 
    return (population->is_running == 1) ? 0 : 1;
}

3.2.6 算子總結

不管是雜交率,還是突變率,都需要根據具體的問題進行設定,然後反覆調整,選擇不當,可能會出現過早收斂,就像第二章中的袋鼠跳例子一樣,過早收斂,就可能搜索到的是局部最優解,也有可能收斂太慢或者無法收斂。當然也可以使用不固定的雜交率或突變率,比如在繁衍的前期可以設置稍微大雨點的值,隨着繁衍,再按照某種規律逐步的減小這些值,就好像顯微鏡的粗調和細調一樣。本例子中所有東西都是選的比較簡單的實現,所以就使用的是固定值。

3.3 具體代碼

由於代碼較多,這裏就沒有全部貼出來。有興趣的話,可以在開源中國上去抓取,網址如下:

https://gitee.com/xuanwolanxue/genetic_maze

四、後續更新

  • 2017/05/18 更新,增加c++版本的遺傳算法以及迷宮遊戲的實現。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章